Dart · Zero dependencies · v3.0.0

Cold, composable
computations for Dart

Jerelo gives you Cont<E, F, A> — a lazy, reusable computation with three outcome channels: success, typed errors, and crashes. Define once. Run anywhere.

$ dart pub add jerelo

Why not just Future?

Dart's Future is great for one-shot async work. But when your business logic needs to be testable, reusable, or configurable at runtime, it falls short.

Future → Cont

Hot vs Cold

A Future starts executing the moment you create it. You can't store it, clone it, or defer it safely.

A Cont is cold — nothing runs until you call .run(). Define once, execute as many times as you need with different environments.

Future → Cont

One Catch-All Error vs Two Dedicated Channels

Future conflates expected business errors with unexpected exceptions. Everything lands in the same catch block.

Cont keeps them separate: else carries typed business errors of type F, while crash handles unexpected exceptions. Errors are data, not exceptions.

Future → Cont

Globals vs Environment Injection

Async Dart flows typically rely on global singletons or scattered constructor injection to pass config and dependencies.

The E type parameter threads an environment through every computation automatically. Use Cont.askThen() to access it anywhere in the chain.

Future → Cont

Manual Parallel Wiring vs First-Class Ops

Coordinating parallel async work with Future.wait requires manual error handling and offers no control over execution policy.

Cont.all, Cont.both, Cont.either, and Cont.any compose parallel computations with configurable policies — quit-fast, sequential, or run-all.

Everything you need, nothing you don't

A small, focused API built around a single composable type. No magic, no framework — just functions that compose.

●

Three-Channel Design

One success channel (then) and two failure channels: else for typed business errors and crash for unexpected exceptions. Errors are data, not exceptions.

❄

Cold Execution

No computation runs until you call .run(). The same Cont can be executed in production, in tests, and in previews — each with its own environment.

👤

Environment Injection

The E type parameter carries config, services, and dependencies without globals. Access it anywhere with Cont.askThen() and scope it with .local().

⚡

Parallel Composition

Cont.both and Cont.all merge computations. Cont.either and Cont.any race them. Configure with quit-fast, sequential, or run-all policies.

🔒

Zero Dependencies

Pure Dart. No external packages, no platform channel, no generated code. Works on Android, iOS, Web, Linux, macOS, and Windows from a single codebase.

🔄

Cancellation

Every .run() returns a ContCancelToken. cancel() is forwarded to whatever is running, and each Cont.fromRun leaf returns a canceller that decides how to stop and reports the result through the usual channels.

♻

Stack-Safe Composition

thenDo, elseDo, crashDo, fromDeferred and local run in a loop, not by recursion. Chains built with fold or in a loop can have any number of steps.

📦

Resource Safety

Cont.bracket acquires a resource, uses it, and runs the release once the use reports back on any channel: success, error, or crash.

🔁

Loops & Retries

thenWhile, elseWhile and crashWhile repeat a computation, with *Until and *Unless variants for conditions. Build retry logic without writing a loop.

See it in action

Real patterns from production-grade Dart code.

import 'package:jerelo/jerelo.dart';

// Build a reusable computation — nothing runs yet.
Cont<AppConfig, String, User> getUserData(String userId) {
  return fetchUserFromApi(userId)
    // Stay on 'then' only if the user is active.
    .thenIf(
      (user) => user.isActive,
      fallback: 'User account is not active',
    )
    // If API fails or user inactive, try cache (with access to env).
    .elseDoWithEnv((config, error) {
      return config.enableCache
        ? loadUserFromCache(userId)
        : Cont.error(error);
    })
    // Side effect: log access without altering the value.
    .thenTap((user) => logAccess(user))
    // Convert any unexpected exception into a typed error.
    .crashRecoverElse((crash) => 'Unexpected error: $crash');
}

// The same computation runs with different configs.
final flow = getUserData('user-42');

flow.run(prodConfig,
  onElse:  (error) => print('Error: $error'),
  onThen:  (user)  => print('Got: ${user.name}'),
);

flow.run(testConfig,
  onElse:  (error) => print('Error: $error'),
  onThen:  (user)  => print('Got: ${user.name}'),
);
import 'package:jerelo/jerelo.dart';

// Run fetch and name-validation in parallel.
// Both must succeed; fail fast on the first error.
Cont<void, String, UserProfile> fetchAndUpdate(
  String accessToken, {
  required String newName,
}) {
  return Cont.both(
    getUserProfile(accessToken),
    validateName(newName),
    (profile, validName) => profile.copyWith(name: validName),
    policy: OkPolicy.quitFast(),
  ).thenDo((merged) {
    return updateUserProfile(accessToken, merged);
  });
}

// Fetch multiple users concurrently; preserve order.
Cont<AppConfig, String, List<User>> getUsers(List<String> ids) {
  return Cont.all(
    ids.map(getUserData).toList(),
    policy: OkPolicy.quitFast(),
  );
}

// Race two strategies — normal login vs. stored refresh token.
// Return whichever succeeds first.
final loginFlow = Cont.either(
  loginRequest('user@test.com', 'password'),
  refreshTokenLogin(storedToken),
  (e1, e2) => '$e1; $e2',
  policy: OkPolicy.sequence(),
);
import 'package:jerelo/jerelo.dart';

// Build the full login-to-session flow.
final loginToSession = loginRequest('user@test.com', 'secret')
  .thenDo((token) {
    return getUserProfile(token.accessToken)
      .thenMap((profile) => Session(token: token, profile: profile));
  })
  // Log the session without changing its value.
  .thenTap((session) {
    print('[LOG] Authenticated as ${session.profile.name}');
    return Cont.of(());
  });

// Execute — all three outcome channels handled explicitly.
final token = loginToSession.run(
  null,
  // Success channel: computation produced a value.
  onThen: (session) {
    print('Welcome, ${session.profile.name}!');
  },
  // Else channel: expected business error (typed as String here).
  onElse: (error) {
    print('Login failed: $error');
  },
  // Crash channel: unexpected exception — never swallowed silently.
  onCrash: (crash) {
    print('Unexpected crash: $crash');
  },
);

// Cancel at any time. The running leaf decides how to stop and
// reports through one of the channels above.
// token.cancel();
import 'dart:async';
import 'dart:io';
import 'package:jerelo/jerelo.dart';

// A leaf returns a canceller: it decides what cancelling means.
Cont<(), String, String> download(String url) {
  return Cont.fromRun((env, observer) {
    final timer = Timer(Duration(seconds: 2), () {
      observer.onThen('contents of $url');
    });
    // Stop the work and report through a channel.
    return () {
      timer.cancel();
      observer.onElse('cancelled');
    };
  });
}

final token = download('https://example.com')
  .thenMap((body) => body.length)
  .run(
    (),
    onElse: (e) => print('else: $e'), // else: cancelled
    onThen: (n) => print('got $n'),
  );

token.cancel(); // forwarded to the running leaf

// bracket: release runs once `use` reports back, on any channel.
Cont<(), String, String> readFile(String path) {
  return Cont.bracket<RandomAccessFile, (), String, String>(
    acquire: openFile(path),
    release: (file) => closeFile(file),
    use: (file) => Cont.of('${file.lengthSync()} bytes'),
  );
}
import 'package:jerelo/jerelo.dart';

// One million sequential steps, built in a loop.
var chain = Cont.of<(), String, int>(0);
for (var i = 0; i < 1000000; i++) {
  chain = chain.thenDo((n) => Cont.of(n + 1));
}

// Runs in constant stack space: no overflow, no lost outcome.
chain.run((), onThen: print); // 1000000

// The same holds for thenMap, elseDo, crashDo,
// Cont.fromDeferred and local, and for recursion:
Cont<(), String, int> count(int i) {
  return i == 1000000
    ? Cont.of(i)
    : Cont.fromDeferred(() => count(i + 1));
}

// A Cont is data, so it can run any number of times,
// also concurrently — each run has its own state.
count(0).run((), onThen: print);
count(0).run((), onThen: print);

Up and running in minutes

Three steps from zero to your first composable computation.

  1. 01

    Add the dependency

    dart pub add jerelo
  2. 02

    Import it

    import 'package:jerelo/jerelo.dart';
  3. 03

    Write your first Cont

    // A cold computation — nothing runs yet.
    final greeting = Cont.of<(), Never, String>('world')
      .thenMap((name) => 'Hello, $name!');
    
    // Run it whenever, as many times as you like.
    greeting.run((), onThen: print); // Hello, world!