Home / Part 2 / 2.4 Error handling
Section 2.4
Error handling
So far a failure has no escape — it unwinds the whole stack and the program ends.
This section adds the node that catches a failure and turns it into something else, and shows
that catchTags and retry are both just that node, used cleverly.
Methods: catchAll · catchTags · retry
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: failure has to land somewhere
Part 1 described handling failure "at the edge." This section is the part of the runtime that
does it. Without it, a typed error is always fatal — it propagates to the top and stops the
program. With it, you can recover: turn a NotFound into a 404, retry a flaky call,
or fall back to a default.
catchAll: the one new node
catchAll builds an OnFailure node holding self and a
handler g. If self fails, g runs with the error and returns
the next effect. If self succeeds, g is skipped entirely.
const recovered = catchAll(
fail("boom"), // self: fails with "boom"
(e) => succeed(`recovered from ${e}`) // g: gets the error, returns the next effect
)
// recovered runs to Success("recovered from boom")
catchAll turns a failing effect into whatever g returns. If
g returns a succeed, the failure is gone — you've recovered. If
g returns a fail, that's a new error, like rethrowing. Either way the
original error has been dealt with; it doesn't keep propagating on its own.
How the runtime catches it
OnFailure is the mirror of OnSuccess. The catching happens in the
unwind half, which now checks the frame kind against whether we're failing:
if (!inFailure && frame._op === "OnSuccess") { current = frame.f(value); break } // value → success step
if ( inFailure && frame._op === "OnFailure") { current = frame.g(failure); break } // failure → handler
// otherwise: skip this frame and keep popping
A value runs the next OnSuccess and skips
OnFailure frames. A failure runs the next OnFailure
and skips OnSuccess frames. That second line is the short-circuit: the happy path
is thrown away until a handler is found.
Here's a failure buried two steps deep, wrapped in a catchAll. It reads inside-out
for now (pipe arrives in 2.6) — the fail is the deepest node, with two
flatMap steps stacked above it:
const program = catchAll(
flatMap(
flatMap(fail("boom"), (x) => succeed(x + 1)), // f2 — never runs
(x) => succeed(x * 2) // f1 — never runs
),
(e) => succeed(`recovered from ${e}`) // g — the handler
)
Step through it. Watch the failure fall past every success step, untouched, until it reaches the handler:
catchTags is catchAll plus a lookup
There's no catchTags node — it's catchAll with a dispatch on
_tag:
catchAll(self, (e) => {
const handler = handlers[e._tag]
return handler ? handler(e) : fail(e) // matched → run it; unmatched → rethrow
})
The types make each handler receive exactly its error variant, and any tag you don't
handle stays in the result's error type (Exclude<E, { _tag: keyof H }>). That's
how, back in Part 1, deleting a handler made the controller stop compiling — the unhandled error
didn't disappear from the type.
retry is catchAll pointing at itself
Also not a primitive. retry catches a failure and, if it's allowed to, runs the
same effect again:
retry(self, { times, while: pred }) =
catchAll(self, (e) =>
times > 0 && pred(e)
? retry(self, { times: times - 1, while: pred }) // run self again, one less try
: fail(e)) // out of tries → give up
Because self is just data, running it again is free — it rebuilds from the same
description. It stops on three conditions: success, no tries left, or an error the predicate
rejects.
catchAll handles one failure. catchTags is
catchAll that branches on the tag. retry is catchAll that
loops back to the start. One node, three uses.catchAll is OP_ON_FAILURE. Real catchTags does the same
guarded lookup — it checks for a _tag before dispatching, so an error without one
passes through. Real retry is more general (it takes a Schedule —
delays, backoff, limits) but at its core it's the same recover-and-rerun loop.
One simplification: we collapse the
Cause. Real Effect doesn't put your raw error in the failure slot — it wraps it in
a Cause that distinguishes a typed failure (Fail) from a defect
(Die — an unexpected throw) from an interruption (Interrupt). Real
catchAll catches only Fail. Our single error slot can't tell them
apart, so it would catch all of them. That Cause layer is what we traded for
simplicity.