Home / Part 2 / 2.1 Foundations
Section 2.1
Foundations
An Effect is data, and a loop runs it. We build the description and the loop that walks it. Everything later is just more node types and a slightly bigger loop.
Methods: succeed · fail · sync · flatMap · map · tap
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: separate describing from doing
A normal function does its work the moment you call it. That's the problem — once it has run, the work is over. You can't retry it, time it, or look at it. It's gone.
So the first move is to stop doing and start describing. Instead of a function that runs, we make a value that says what would run. Then a separate thing — the runtime — takes that value and carries it out later, when we ask. Describing and doing become two steps instead of one.
What an Effect actually is
Two halves, worth keeping apart. The public type is opaque — it carries only
A (the value on success) and E (how it can fail), and is deliberately
empty of detail so inference stays clean. The internal value is a plain tagged
object, a Primitive:
type Primitive =
| { _op: "Succeed"; value: unknown }
| { _op: "Failure"; error: unknown }
| { _op: "Sync"; thunk: () => unknown }
| { _op: "OnSuccess"; self: Effect; f: (a) => Effect }
Each constructor builds one of these and casts it to Effect<A, E>. The runtime
casts back and reads _op. Same object, two faces: an Effect<number>
to you, a { _op: "Succeed" } to the loop. The surface is typed; the core uses casts.
Real Effect is built exactly this way.
The constructors
succeed, fail, and sync are the leaves — they don't wrap
other effects:
succeed(10) // { _op: "Succeed", value: 10 }
fail("boom") // { _op: "Failure", error: "boom" }
sync(() => Date.now()) // { _op: "Sync", thunk: () => Date.now() }
succeed and fail hold a value that's ready now. sync holds
a thunk — a function the runtime runs when it reaches that node. The difference is
timing: succeed(Date.now()) captures the time you build the description;
sync(() => Date.now()) captures it when the description runs.
flatMap: the one that needs a stack
flatMap is "run an effect, then use its result to build the next effect":
flatMap(succeed(10), (n) => succeed(n * 2))
It builds an OnSuccess node holding self (the first effect) and
f (what to do with its value). Here's the catch: to run f the loop needs
the value of self, and self hasn't run yet. So f can't run
now — it has to be set aside until self produces a value.
"Set aside" is a stack:
case "OnSuccess":
stack.push(node.f) // postpone f
current = toPrimitive(node.self) // run the child first
continue
That's the only reason the stack exists: to hold the f of
a step whose input isn't ready yet.
map and tap are just flatMap
Neither is a primitive — there's no Map or Tap node. Both are built
from flatMap:
map(self, f) = flatMap(self, (a) => sync(() => f(a))) // wrap the plain result back into an effect
tap(self, f) = flatMap(self, (a) => map(f(a), () => a)) // run f(a), then ignore it and keep a
So the rule you'll use constantly:
map(self, a => plainValue) // your function returns a value
flatMap(self, a => anEffect) // your function returns the next effect
tap(self, a => anEffect) // run an effect, keep the original value
The runtime: registers and a loop
The runner is the whole engine. It holds a handful of registers:
current— the node it's about to run.value/failure— the result of the last node.inFailure— which one is live: are we carrying a value, or an error?stack— the postponedfs.
And it loops over two halves: dive into a node, or unwind by
popping a postponed step. Here's the program it runs below — succeed(10), doubled,
plus one, with a tap that logs:
const program = tap(
map(
flatMap(succeed(10), (n) => succeed(n * 2)), // f1: ×2
(n) => n + 1 // f2: +1
),
(n) => sync(() => console.log("tap sees:", n)) // f3: tap
)
Step through how the loop runs it. Watch the value register and the stack:
The loop alternates the whole way: dive, dive, dive to a leaf, then unwind through each
postponed f. The stack fills as it dives into the chain, then drains as the value
bubbles back up.
Failure is the same loop, one check different
Take a program where a step fails partway, with a map after it:
const program = map(
flatMap(succeed(1), () => fail("boom")), // fails here
(n) => n + 1 // this never runs
)
When the loop hits the Failure node, it writes the failure register
and sets inFailure = true. The unwinding half then does one thing differently:
let frame = stack.pop()
while (frame) {
if (!inFailure) { current = frame(value); break } // value → run the step
frame = stack.pop() // failure → discard the step
}
A value runs the next postponed step. A failure discards it and keeps popping.
So once we're failing, every postponed step on the happy path gets thrown away, one after
another, until the stack is empty and we finish as a Failure.
try/catch rebuilt from a flag and a stack. There's nothing to catch the failure yet — that's Section 2.4. For now, a failure just unwinds everything.The node names come from the Effect source. There the op-codes are OP_SUCCESS,
OP_FAILURE, OP_SYNC, and OP_ON_SUCCESS — our
Succeed, Failure, Sync, and OnSuccess.
map there is also flatMap(self, a => sync(() => f(a))), the same
definition we used.
One honest simplification: when our Sync
thunk throws, we stuff the thrown thing into the failure register. Real Effect
treats an unexpected throw as a defect — a separate channel from typed failures. We
meet that distinction in Section 2.4.