Home / Part 2 / 2.6 Ergonomics
Section 2.6
Ergonomics
This section adds no runtime behavior. It's the sugar that makes the API pleasant — and the point is to see clearly that the pleasantness is a source-level thing, gone by the time the runtime runs.
Methods: pipe · dual
This section's runtime is self-contained. Read it, run example.ts with bun, and step through it with a debugger as you read.
Why this exists: nested calls read inside-out
To follow a chain of nested calls, you read from the innermost outward — the first thing that
runs is buried deepest, the last thing is on the outside. pipe flips it: the same
steps, listed top-to-bottom, in the order they run.
provide(
catchAll(
timeout(
tap(
map(
retry(
flatMap(service(Api), (api) => api.get("/user")),
{ times: 5, while: (e) => e._tag === "Network" }),
(b) => b.toUpperCase()),
(v) => sync(() => console.log(v))),
1000),
(e) => succeed(`recovered`)),
Api, ApiLive)
pipe(
flatMap(service(Api), (api) => api.get("/user")),
retry({ times: 5, while: (e) => e._tag === "Network" }),
map((body) => body.toUpperCase()),
tap((value) => sync(() => console.log(value))),
timeout(1000),
catchAll((e) => succeed(`recovered`)),
provide(Api, ApiLive)
)
Same tree, readable order: fetch, retry, uppercase, log, time-limit, recover, provide. They build the identical node structure — only the source reads differently.
pipe is just function application
function pipe(a, ...fns) {
return fns.reduce((acc, fn) => fn(acc), a)
}
// pipe(x, f, g, h) is exactly h(g(f(x)))
There's nothing clever here. The overloads above the implementation only thread the types through
each step so inference works. pipe runs while you build the effect, not while
it runs — by the time runPromise sees the result, pipe is gone; it
already did its job of assembling the tree.
The problem dual solves
For pipe(x, f, g) to work, each step must be a function that takes the value and
returns the next. But map(self, f) takes two arguments — you can't drop it
into a pipe, which only hands it one thing. So each combinator needs to work two ways, and
dual makes one definition do both by counting arguments:
const dual = (arity, body) => (...args) =>
args.length >= arity
? body(...args) // enough args → run now (data-first)
: (self) => body(self, ...args) // too few → wait for self (data-last)
self, the effect — because that's the one pipe will supply. The body is written self-first; the short call holds that slot open.The rule for which combinators get dual is simple: the transformers —
flatMap, map, tap, catchAll, retry,
timeout, provide — all take a self first, so they're dual.
The constructors (succeed, fail, service) build an
effect from plain inputs; there's no self to defer.
The real library works the same way. Every pipeable combinator is built with
an internal dual helper that counts arguments, and import { pipe } from
"effect" is the same left-to-right applier. None of it is runtime machinery — it runs
while you build the effect, and is gone by the time the interpreter walks it.
That's the whole runtime. Six sections in, an Effect is a tree of tagged nodes and a loop that walks it — and the loop can sequence, fail, pause, carry dependencies, and run many at once. The last section puts all of it to work on a real program.