Developer Experience (DX) is often about finding the right level of abstraction. While building out the database and event log adapters for Anabranch, I spent time thinking about how to get the Task composition just right. In this ecosystem, a Task is the fundamental building block for a single async action. It is a more resilient alternative to a raw Promise that includes built-in support for retries, timeouts, and signals. It sits alongside other primitives like pull-based Stream and push-based Channel with first-class backpressure support, all designed to compose together into a cohesive whole.
Utils, Libraries, and Frameworks
Anabranch occupies a unique space between specialized utilities and all-encompassing frameworks. By building it as an ecosystem rather than just a library, the goal is to provide a cohesive experience where different parts of the stack speak the same language. Let’s look at how a common scenario (fetching a user, calling an API with retries, and updating a database) compares across three different libraries across these scopes.
Anabranch: An Ecosystem
Anabranch is built as a set of interoperable abstractions for the modern stack, including db, eventlog, queue, and storage. Because every part of the ecosystem speaks the same Task language natively, we remove the friction of manual wrapping while keeping the high-level composition flat and readable.
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. While extremely powerful, it requires lifting promises into the Effect runtime (e.g., via Effect.promise) and uses a more specialized scheduling API for resilience. It's less of a library and more of a total architectural choice.
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
One of the most satisfying parts of this implementation was getting the type inference to "roll" correctly through the array. However, TypeScript has inherent limits when inferring types across generic array elements. To keep the compiler fast and the types predictable, Task.chain is implemented via overloads that support up to seven levels of sequential inference.
This covers almost all common use cases. If a process grows beyond seven steps, it's usually a signal that the logic should be decomposed into smaller, nested chains or simply handled with standard flatMap calls between two separate chains. This deliberate design choice keeps the developer experience smooth while maintaining a high-performance type system.
The philosophy of Anabranch is to provide about 90% of the benefit of a full functional stack with 10% of the cognitive overhead. It's about making the right thing the easy thing.