Section 2.2
Async
Section 2.1's loop ran a whole tree, but every node finished instantly. Real programs wait — on a network call, a timer, a file. We add the one node that can take time, and the one trick that makes it work: the loop can stop, and start again later.
Methods: async · tryPromise
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: the loop can't just wait
When the loop hits a node that takes 50ms, it has a problem. It can't sit and spin — that freezes the thread. It can't block — JavaScript is single-threaded, so blocking freezes everything too. So it does the only thing left: it stops, and arranges to be restarted when the answer arrives.
That's the entire idea. A slow node says "I don't have a value yet — here's how to reach me when I do," and the loop walks away until it's reached.
The Async node
{ _op: "Async"; register: (resume: (effect) => void) => void }
It holds one function, register. The runtime calls it and hands it a
resume callback. Two sides, two owners:
register— you write it. "Start the work." (Start a timer, a fetch, listen for an event.)resume— the runtime writes it. "I have my answer, runtime, continue." You call it when the work is done, passing asucceed(...)or afail(...).
It's callback-based because the bottom layer of JavaScript is callback-based —
setTimeout, socket.on, fs.readFile, even
new Promise. async is where our toy meets that layer, so it speaks its
language.
A small concrete use — a value that arrives after 100ms:
const delayed = async((resume) => {
setTimeout(() => resume(succeed("done!")), 100) // start the work; resume when it lands
})
How the loop pauses and resumes
case "Async": {
const resume = (next) => {
current = toPrimitive(next) // the result, as an effect
step() // re-enter the loop
}
node.register(resume) // start the work
return // LEAVE the loop. the thread is now free.
}
The order matters: build resume, call register(resume) (this starts
the work now), then return — the loop exits, the call stack empties, the
thread is free. Later the work finishes and calls
resume(succeed(value)), which sets current and calls
step() again.
return out of step. "Resume" is just calling step again. Everything in between survives because the registers live in a closure.You never wrote await. The loop stepped out on a slow node and
came back. A sync node and an async node sit in the same chain; the only difference is that the
async one makes the loop leave and re-enter. So sync and async compose the same way, with
nothing marked in the code.
tryPromise is built on async
No special promise handling — it's a thin wrapper. .then is the
register: it starts the promise and arranges to call resume on settle.
A resolve becomes succeed; a reject runs your catch to make a typed
error and becomes fail:
const tryPromise = (options) =>
async((resume) => {
options.try().then(
(value) => resume(succeed(value)),
(error) => resume(fail(options.catch(error)))
)
})
So when you use tryPromise (or, in Part 1, Effect.tryPromise), you're
using this callback dance — it's just written for you. You hand over () => fetch(...)
and never see resume.
Why running now returns a Promise
In 2.1, the runner handed back the answer immediately, because every node finished at once. Now
a node can pause — so the runner might be parked when you call it. That's why the main runner is
now runPromise: it gives you a Promise up front and resolves it from inside
resume, whenever that fires.
const runSyncExit = (effect) => {
let result
unsafeRun(effect, (exit) => { result = exit })
if (result === undefined) throw new Error("async — use runPromise")
return result
}
If the tree is fully sync, unsafeRun finishes before it returns, so
result is set. If it parks on an Async node, unsafeRun
returns with result still unset — so we throw. This is exactly the real
runSync vs runPromise split: runPromise always works,
runSync is an opt-in "I promise this is sync" that fails loudly if you're wrong.
Once, at the edge, when you run. Everywhere else, sync and async are the same
Effect.
async is OP_ASYNC in the Effect source — same shape,
with two additions we'll get to in 2.5: the register also receives an AbortSignal,
and it can return a canceler, both used for interruption. The real
tryPromise is the same .then dance we wrote.