Home / Part 2

Part 2 — The how

How it works under the hood

Part 1 ended on one claim: an Effect is a description, not a running program. Now we make that literal — we build the description, and we build the loop that walks it. By the end you'll have built every piece yourself.

Follow along with the code

Part 2 builds a small Effect runtime from scratch. Each section is a folder with a self-contained runtime.ts and a runnable example.ts — read them, run them with bun, and step through them with a debugger as you read.

We rebuild a small, fully-typed version of Effect across seven sections. The first six (2.1–2.6) build the runtime, each adding to the same engine; the last (2.7) uses it to write a real program:

2.1 foundations an Effect is data · a loop runs it 2.2 async the loop pauses & resumes 2.3 context dependencies as data · the R slot 2.4 errors a flag that short-circuits 2.5 concurrency more than one loop 2.6 ergonomics sugar, gone before runtime
The six runtime sections, cumulative: each is a folder of runnable code that extends the one below it. Section 2.7 then uses the finished runtime to build a real program.

One object, two views

One structural idea makes all of this work, so it's worth getting straight up front.

To you, an Effect is an abstract type. You only care about three things about it: the value it produces, how it can fail, and what it needs — A, E, R. At runtime, the same value is a plain object with an _op tag and whatever fields the interpreter needs to run that node. The constructors build the object and label it as an Effect<A, E, R>; the interpreter reads it back as a tagged node, casting between the two views.

What you write against — the abstract type
// carries only A, E, R.
// no fields, so inference stays clean.
interface Effect<out A, out E, out R> {
  readonly _op: string
}
What the interpreter reads — a tagged object
// _op says which node; the rest is what running it needs.
type Primitive =
  | { _op: "Succeed";   value: unknown }
  | { _op: "Failure";   error: unknown }
  | { _op: "Sync";      thunk: () => unknown }
  | { _op: "OnSuccess"; self: Effect; f: (a) => Effect }
  // ... more nodes get added each section

So the surface stays fully typed while the interpreter works on plain tagged objects. Real Effect is built the same way. With that in mind, every section is just "add a node type, add a case to the loop."

The seven sections

Grounded in the real source

Every node we build maps to a real op-code in the Effect source (OP_SUCCESS, OP_ASYNC, OP_ON_FAILURE, …). Where our toy simplifies something, the section says so.