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.