Section 2.3
Context
This is where the type finally grows its third slot. So far it was
Effect<A, E>. Now it's Effect<A, E, R>, and R
tracks what the effect still needs from the outside before it can run.
Methods: service · provide
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: stop threading dependencies by hand
Part 1 showed the pain: the repository needs a database, but to get it there you pass
db through every function in between. The fix is the same move we make every time —
turn the thing into data. Instead of passing the database as an argument, a
function just says "I need the Database," as a node in the description. The runtime carries a bag
of dependencies as it walks, and fills the need in when it reaches the node. Nobody in between
has to know.
A Tag names a dependency
interface Database { findUser: (id: string) => string }
const Database = makeTag<Database>()("Database")
Database is now both a value (carrying the key "Database") and a type
(remembering the service is { findUser: ... }). It's how you refer to "the database
dependency" in both worlds.
service reads a need · provide supplies it
service(Database) is an effect that produces the database — and, crucially, records
the need by putting the tag's key into R. Because flatMap unions the
Rs of its parts (just like it unions the Es), the requirement flows up
through every function that uses it, without any of them naming it. provide is the
other end: it supplies the implementation and removes the key from R.
Here it is end to end with the toy runtime — read the dependency, supply it, run:
const getUserName = (id: string) =>
flatMap(service(Database), (db) => succeed(db.findUser(id)))
// getUserName("1") : Effect<string, never, "Database"> ← R records the need
const live: Database = { findUser: (id) => `user-${id}` }
runSync(provide(getUserName("1"), Database, live)) // "user-1"
// runSync(getUserName("1")) ← won't compile: R is still "Database"
R as a union of dependency keys. service adds one; provide removes one (Exclude<R, "Database">). When R is never, nothing is missing — and only then will the runners accept the program.service(Database) : Effect<Database, never, "Database"> // the need, in R
provide(greet("1"), Database, live) // R: "Database" → never
That's why runSync(provide(greet("1"), Database, live)) compiles but
runSync(greet("1")) does not: the runners require R = never. The
compiler rejects running an effect that still needs something. It's also the testing story made
concrete — provide the live database in one place, a fake one in another. Same
greet, different provide, no mocking library.
How the runtime handles it: one register, two nodes
The runtime gains a context register — a Map from key to
implementation. Service reads from it (it's just
Succeed, except the value comes from the context).
Provide writes to it — and this is the subtle one:
case "Provide": {
const previous = context
context = new Map(previous) // copy, don't mutate
context.set(node.key, node.impl) // add the impl
stack.push({ _op: "RestoreContext", context: previous }) // schedule the undo
current = toPrimitive(node.self) // run self with the new context
}
Two things to notice. It copies the context instead of mutating it — so the new
dependency is scoped to self only, and you can provide different implementations to
different parts of one program without them stepping on each other. And it pushes a
RestoreContext frame so the old context comes back once self is done.
self — added on the way in, removed on the way out.service is OP_TAG. The context register is real — Effect carries an
immutable Context while running, and provide layers onto it.
Two honest simplifications. Real Effect has no
Provide primitive; providing rides on OP_WITH_RUNTIME. We added an
explicit node because it's clearer to see. And we model R as a union of string
keys; real Effect uses the tag's identity type, so two tags can share a shape without
colliding. The behavior — add a need, remove it, run when R is empty — is the
same.