While building the database and event log adapters for Anabranch, I spent a while getting Task composition right. A Task is a single async action: a lazy Promise with retries, timeouts, and signals built in. It sits alongside Stream (pull-based) and Channel (push-based, with backpressure).

Utils, Libraries, and Frameworks

Anabranch sits between a utility library and a framework. Here's one scenario (fetch a user, call an API with retries, update a database) written three times, in libraries at three different scopes.

Anabranch: An Ecosystem

Every package (db, eventlog, queue, storage) returns Tasks, so there is nothing to lift and the chain stays flat.

import { Task } from "@anabranch/anabranch";
import { createClient } from "@anabranch/web-client";

const id = "user-123";
const client = createClient(...);

const result = await Task.chain([
  () => db.query<User>(...),
  user => client.post<ProcessResult>(...)
    .retry({ attempts: 3, delay: a => 100 * 2 ** a })
    .timeout(5000),
  res => db.execute(...),
]).result();

result.type === "success"
  ? console.log("Success:", result.value)
  : console.error("Error:", result.error);

RxJS: A Utility Library

RxJS focuses on observable streams. To use it with a standard promise-based client like Axios, you must lift the promise using from. Chaining requires specific operators like switchMap to handle the flattening of async results.

import { from, timer } from 'rxjs';
import { switchMap, retry, timeout } from 'rxjs/operators';
import axios from 'axios';

const id = "user-123";
const client = axios.create(...);

from(db.query<User>(...)).pipe(
  switchMap(user => 
    from(client.post<ProcessResult>(...)).pipe(
      retry({ count: 3, delay: (err, i) => timer(100 * 2 ** i) }),
      timeout(5000)
    )
  ),
  switchMap(res => from(db.execute(...)))
).subscribe({
  next: (val) => console.log("Success:", val),
  error: (err) => console.error("Error:", err)
});

Effect: A Backend Framework

Effect is a complete functional stack. Promises have to be lifted into the Effect runtime via Effect.promise, and resilience goes through the Schedule API. It's less a library than an architectural commitment.

import { Effect, Schedule, Exit } from "effect";
import axios from "axios";

const id = "user-123";
const client = axios.create(...);

const program = Effect.gen(function* (_) {
  const user = yield* _(Effect.promise(() => db.query<User>(...)));
  const res = yield* _(
    Effect.promise(() => client.post<ProcessResult>(...))
      .pipe(
        Effect.retry(Schedule.exponential(100).pipe(Schedule.recurs(3))),
        Effect.timeout("5 seconds")
      )
  );
  return yield* _(Effect.promise(() => db.execute(...)));
});

const result = await Effect.runPromiseExit(program);

Exit.isSuccess(result)
  ? console.log("Success:", result.value)
  : console.error("Error:", result.cause);

The Limits of Inference

The fiddly part was getting the type inference to "roll" through the array: each step's parameter type comes from the previous step's return type. TypeScript can't do that generically across array elements, so Task.chain is a stack of overloads, one per arity, up to seven steps.

Past seven, you nest chains or flatMap between two of them. In practice a seven-step chain is already asking to be split up.

Most of what a full functional stack gives you, without having to adopt one.