Home / Part 2 / 2.3 Context

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

Follow along with the code

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"
service(Database) R = "Database" adds a need greet, handler, … R = "Database" rides up untouched provide(…, Live) R = never removes it → runnable
We model 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.

before { } provide while running self — copied & extended { "Database" → impl } restore after { } The RestoreContext frame runs on the way back up whether we carry a value or a failure. It's the first frame that fires regardless of success — the same pattern that later powers cleanup.
The dependency exists for exactly the duration of self — added on the way in, removed on the way out.
Where this maps in real Effect

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.