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

Follow along with the code

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.

nested — read inside-out
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)
piped — read top-to-bottom
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)
map( … ) how many args? map(self, f) → run now map(f) → (self) ⇒ … for pipe
The argument it always defers is 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 transformersflatMap, 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.

Where this maps in real Effect

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.