21 - Context: Typed Dependency Injection
The R parameter in Effect<A, E, R> is the requirements set, a union of services the effect needs before it can run. Context.Service is the lowest-level building block for declaring a service.
This step shows how a Tag becomes a typed key, how yield* looks it up, and how Effect.provideService satisfies it.
Note: Most application code should use
Context.Service(covered in step 23) which defines both the Tag and a Layer in a single declaration.Context.Serviceis the underlying primitive, useful to understand, and still appropriate when you need the lowest-level control.
What you’ll work through
index.test.ts contains four beats: declaring a Context.Service, yielding the Tag inside Effect.gen to access the service, satisfying the requirement with Effect.provideService, and watching how R propagates through a chain of effects that share a tag.
By the end you should understand that R is a static set the compiler tracks, and a Tag is the unique typed key that closes the gap.
The Dictionary Metaphor
A Context is a typed dictionary keyed by Tags. A Tag is a constructor that yields a typed key; yielding the Tag inside Effect.gen looks up the service at runtime.
Context (runtime dictionary, populated at the app boundary) ┌─────────────────────────────────────────────┐ │ Logger → { log: (msg) => Effect<void> } │ │ Random → { next: () => Effect<number> } │ └─────────────────────────────────────────────┘ ▲ ▲ yield* Logger yield* Random │ │ └──── used inside Effect.gen ────┘ │ Effect<A, E, Logger | Random> ▲ │ R = union of every required TagWhen R = never, every requirement is satisfied and the effect is runnable. Until then, the type checker refuses to compile a call to runSync / runPromise.
Context.Service
Declares a service. The class extends Context.Service(id) with two type parameters: the class itself (Self) and the service interface.
import { Effect, Context } from "effect"
class Logger extends Context.Service<Logger, { readonly log: (message: string) => Effect.Effect<void> }>()("app/Logger") {}Details
The string id is the runtime key. It must be globally unique, two Tags with the same id will silently collide.
Prefix with your package or domain (app/Logger, payments/Stripe).
Logger is now both a type (the service interface) and a value (the Tag). The type parameters live entirely in TypeScript; at runtime, the Tag is identified by the id string.
When to Use
When you want to declare a service contract without committing to an implementation, or when you need explicit control over the Tag (variance, generic Tags, custom dispatch). For typical application services, prefer Context.Service (step 23).
Your turn observe how the Tag's `key` matches the id string at runtime.
yield* a Tag: Service Lookup
Yielding a Tag inside Effect.gen returns the service value and adds the Tag to R.
import { Effect } from "effect"
// ┌─── Effect<string, never, Logger>// ▼const greet = (name: string) => Effect.gen(function* () { const logger = yield* Logger yield* logger.log(`Hello, ${name}`) return `Hello, ${name}!` })Details
R widens to include Logger. Calling another function that also needs Logger does not duplicate the requirement, the union absorbs it.
Calling one that needs Random widens to Logger | Random.
Your turn yield the `Logger` (and `Random`) Tag inside `Effect.gen` so the program's `R` widens to a union.
Effect.provideService
Removes a Tag from R by supplying the implementation directly.
import { Effect } from "effect"
// ┌─── Effect<string, never, never>// ▼const runnable = greet("Alice").pipe( Effect.provideService(Logger, { log: (msg) => Effect.sync(() => console.log(msg)), }))Details
provideService is appropriate at the application boundary (main, request handlers, test setup). Calling it inside business logic hard-codes the implementation and defeats the purpose of DI, for that case, accept a Layer and let the caller wire it.
For multiple services or services with dependencies, use Layer (next step) and Effect.provide.
When to Use
Single-service tests, scripts, and the simplest entry points. Anything more elaborate belongs in a Layer.
Your turn pipe `Effect.provideService(Logger, ...)` so the silent test logger captures the messages.
A Full Example
Two services, an effect that uses both, and a single entry point that provides them.
import { Effect, Context } from "effect"
class Logger extends Context.Service<Logger, { readonly log: (message: string) => Effect.Effect<void> }>()("app/Logger") {}
class Random extends Context.Service<Random, { readonly next: () => Effect.Effect<number> }>()("app/Random") {}
// ┌─── Effect<number, never, Logger | Random>// ▼const program = Effect.gen(function* () { const logger = yield* Logger const random = yield* Random
const value = yield* random.next() yield* logger.log(`Got: ${value}`) return value})
const result = Effect.runSync( program.pipe( Effect.provideService(Logger, { log: () => Effect.void }), Effect.provideService(Random, { next: () => Effect.succeed(42) }) ))// 42The program does not change between production and test. Only the provided implementations differ.
Your turn observe how `getGreeting` inherits `Logger` from `getUser` without redeclaring it.
Rules of thumb
- Prefer
Context.Service(step 23) for app code.Context.Serviceis for fine-grained control over the Tag/shape relationship. - Tags carry identity and type. A
class extends Context.Service(...)gives you both in one place — use that form. Ris an obligation. AEffect<A, E, R>cannot run until every member ofRis provided; the type tells you exactly what to layer in.
Common Pitfalls
1, Running an effect with unsatisfied R. TypeScript blocks Effect.runSync(greet("Alice")) when R still contains Logger. Provide every required service before calling a runner.
2, Colliding Tag ids. Two Tags created with Context.Service("Logger") will resolve to the same runtime key. Prefix with your domain: Context.Service("app/Logger").
3, Calling provideService inside business logic. The implementation becomes hard-coded, undoing the dependency inversion. Provide at the entry point only.
4, Reaching for Context.Service when Context.Service would do. Context.Service (step 23) combines the Tag and a default Layer in one declaration. Prefer it for ordinary application services.
when a layer provides one.
See Also
- Previous,
11-option-either: lighter alternatives toEffect. - Next,
22-layers: packaging implementations as composable Layers. - Related,
23-service-driven: the modernContext.Servicepattern. - Later,
24-advanced-di: shared instances and complex graphs.
Run the tests: pnpm test 21-context-basics
The full test suite
The index.test.ts for this lesson, verbatim from steps/21-context-basics/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Context } from "effect"
// =============================================================================// 11 - Context: Typed Dependency Injection// =============================================================================//// Context.Service declares a service. yield* the Tag inside Effect.gen to look it// up; the Tag is added to R. Effect.provideService removes it from R.
class Logger extends Context.Service<Logger, { readonly log: (message: string) => Effect.Effect<void> }>()("app/Logger") {}
class Random extends Context.Service<Random, { readonly next: () => Effect.Effect<number> }>()("app/Random") {}
describe("11 - Context Basics", () => { // @beat // id: declare-tag // shape: multi-todo // title: A Tag carries its id as a runtime key // teaches: A Context.Service becomes both a type and a value identified by a unique key string. // hint: Tag.key equals the string you passed to Context.Service("…") describe("Context.Service", () => { it("the Tag carries its id as a runtime key", () => { // Predict: the key strings passed into Context.Service(...) above expect(Logger.key).toBe(/* @stub */ "app/Logger" /* @end */) expect(Random.key).toBe(/* @stub */ "app/Random" /* @end */) }) }) // @end-beat
// @beat // id: yield-a-tag // shape: multi-todo // title: Yield a Tag to look up a service // teaches: yield* on a Tag returns the service value and adds the Tag to R; multiple yields widen R to a union. // hint: const logger = yield* Logger describe("yield* a Tag inside Effect.gen", () => { it("looking up the service adds the Tag to R", async () => { // ┌─── Effect<string, never, Logger> // ▼ const program = Effect.gen(function* () { const logger = yield* /* @stub */ Logger /* @end */ yield* logger.log("hello") return "done" })
const result = await Effect.runPromise( program.pipe( Effect.provideService(Logger, { log: () => Effect.void }) ) )
expect(result).toBe("done") })
it("multiple yields widen R to a union of Tags", async () => { // ┌─── Effect<number, never, Logger | Random> // ▼ const program = Effect.gen(function* () { const logger = yield* /* @stub */ Logger /* @end */ const random = yield* /* @stub */ Random /* @end */
const value = yield* random.next() yield* logger.log(`got ${value}`) return value })
const result = await Effect.runPromise( program.pipe( Effect.provideService(Logger, { log: () => Effect.void }), Effect.provideService(Random, { next: () => Effect.succeed(42) }) ) )
expect(result).toBe(42) }) }) // @end-beat
// @beat // id: provide-service // shape: one-todo // title: Satisfy R with provideService // teaches: Effect.provideService supplies an implementation and removes its Tag from R; same program runs against any implementation. // hint: program.pipe(Effect.provideService(Tag, implementation)) describe("Effect.provideService", () => { it("the same program runs against a silent test logger and would run against console in prod", async () => { const captured: Array<string> = []
const program = Effect.gen(function* () { const logger = yield* Logger yield* logger.log("event: signed up") return "logged" })
const result = await Effect.runPromise( program.pipe( /* @stub */ Effect.provideService(Logger, { log: (msg) => Effect.sync(() => { captured.push(msg) }), }) /* @end */ ) )
expect(result).toBe("logged") expect(captured).toEqual(["event: signed up"]) }) }) // @end-beat
// @beat // id: requirements-propagate // shape: one-todo // title: Requirements propagate automatically // teaches: A caller that uses a service-needing function inherits its R; provide once at the entry point. // hint: getUser returns { name: "Alice" } — getGreeting interpolates that into a template string describe("requirements propagate automatically", () => { it("a caller that uses a service-needing function inherits the requirement", () => { const getUser = (id: string): Effect.Effect<{ name: string }, never, Logger> => Effect.gen(function* () { const logger = yield* Logger yield* logger.log(`fetch ${id}`) return { name: "Alice" } })
// ┌─── Effect<string, never, Logger> // ▼ const getGreeting = (id: string) => Effect.gen(function* () { const user = yield* getUser(id) return `Hello, ${user.name}!` })
const result = Effect.runSync( getGreeting("123").pipe( Effect.provideService(Logger, { log: () => Effect.void }) ) )
// Predict the final greeting string expect(result).toBe(/* @stub */ "Hello, Alice!" /* @end */) }) }) // @end-beat})