Effect TS

A course for TypeScript developers

Dependency Injection

2 steps
11
Services & Context
The R in Effect<A, E, R>
15 min

The R type parameter tracks what an Effect NEEDS to run. This is Effect's built-in dependency injection — no framework needed. In TypeScript you'd wire dependencies manually or use a DI container. Effect makes dependencies part of the type system.

Key InsightUse Effect.Service to define services as classes with auto-generated layers. The R type ensures you provide all dependencies before running — the compiler is your DI container.
What to learn
Effect.Service
Defines a service as a class with tag, implementation, and auto-generated layers (Default, DefaultWithoutDependencies). The recommended way to create services.
succeed option
Use when the implementation is a plain object — no async, no dependencies needed to build it. Example: { succeed: { log: (msg) => Effect.sync(...) } }
effect option
Use when building the service requires an Effect — e.g. you need to yield* another service during construction. Example: { effect: Effect.gen(function* () { const dep = yield* Other; return { ... } }) }
R type parameter
Accumulates required services. Effect<User, Error, Database | Logger> needs both Database and Logger provided before it can run.
yield* ServiceTag
Inside Effect.gen, yield* MyService pulls the service from context. Type-safe — the compiler adds it to R automatically.
Context.GenericTag
Lower-level API to create a service tag without Effect.Service. Useful for simple services or understanding what Effect.Service abstracts.
dependencies option
Pass dependencies: [OtherService.Default] in Effect.Service to wire up the dependency graph declaratively. No manual pipe/provide needed.
Effect.provideService(tag, impl)
Provides one service implementation inline. Removes it from R. Great for tests.
In TypeScript
// TypeScript: manual dependency wiring
interface Database {
  query(sql: string): Promise<unknown[]>
}

function createDatabase(pool: Pool): Database {
  return { query: (sql) => pool.query(sql) }
}

// Caller must remember to pass the right pool.
// Nothing prevents passing the wrong one.
// Nothing tracks which functions need a Database.
const db = createDatabase(pool)
const users = await db.query("SELECT * FROM users")
With Effect
import { Effect, Context } from "effect"

// === Option 1: Effect.Service (recommended) ===
class Database extends Effect.Service<Database>()("Database", {
  effect: Effect.gen(function* () ({
    query: (sql: string) => Effect.tryPromise(() => pool.query(sql))
  })),
  dependencies: [ConnectionPool.Default]
}) {}

// Auto-generated:
// Database.Default — includes all dependencies
// Database.DefaultWithoutDependencies — requires them externally

const getUsers = Effect.gen(function* () {
  const db = yield* Database  // pulls from context, adds Database to R
  return yield* db.query("SELECT * FROM users")
})
// Type: Effect<User[], SqlError, Database>

// === Option 2: Context.GenericTag (lower-level) ===
interface Logger {
  readonly log: (msg: string) => Effect.Effect<void>
}
const Logger = Context.GenericTag<Logger>("Logger")

const program = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.log("hello")
})
// Type: Effect<void, never, Logger>

// Provide and run
program.pipe(
  Effect.provideService(Logger, {
    log: (msg) => Effect.sync(() => console.log(msg))
  }),
  Effect.runPromise
)
How it works
  TS interface                     Effect Service
  ─────────────                    ──────────────
  interface Logger {               class Logger extends
    log(msg: string): void           Effect.Service<Logger>()("Logger", {
  }                                    succeed: { log: ... }
                                     }) {}
          │                                │
          │                                ├── Tag (unique identity)
          │                                ├── .Default (auto Layer)
          │                                └── R type tracking
          │                                        │
          ▼                                        ▼
  You wire it manually.            Compiler enforces it:
  Forget? Runtime error.           R ≠ never? Won't compile.


  ┌─────────────────────────────────────────────────────────┐
  │  yield* Logger     ← pulls Logger from context          │
  │         │                                               │
  │         └──► adds Logger to R automatically             │
  │                                                         │
  │  Effect<void, never, Logger>                            │
  │                      ^^^^^^                             │
  │              "this needs a Logger to run"               │
  │                                                         │
  │  pipe(Effect.provideService(Logger, impl))              │
  │                                                         │
  │  Effect<void, never, never>                             │
  │                      ^^^^^                              │
  │              "all dependencies satisfied — can run"     │
  └─────────────────────────────────────────────────────────┘
Practice
Define a service and use it

Create a Clock service using Effect.Service that has a single method `now` returning an Effect with the current timestamp. Then write a program that uses the service.

import { Effect } from "effect"

// TODO: Define a Clock service with Effect.Service
// It should have a method: now: () => Effect.Effect<number>
// Use Effect.sync(() => Date.now()) in the implementation

// TODO: Write a program that uses Clock to get the current time
// const program = Effect.gen(function* () { ... })
Reveal solution
import { Effect } from "effect"

class Clock extends Effect.Service<Clock>()("Clock", {
  succeed: {
    now: () => Effect.sync(() => Date.now())
  }
}) {}

const program = Effect.gen(function* () {
  const clock = yield* Clock
  const time = yield* clock.now()
  return time
})
// Type: Effect<number, never, Clock>

// Run it:
// program.pipe(Effect.provide(Clock.Default), Effect.runPromise)
Swap a service for testing

Given the Clock service below, provide a fake implementation that always returns 0. Use Effect.provideService to swap the real implementation for a test one.

import { Effect } from "effect"

class Clock extends Effect.Service<Clock>()("Clock", {
  succeed: {
    now: () => Effect.sync(() => Date.now())
  }
}) {}

const program = Effect.gen(function* () {
  const clock = yield* Clock
  return yield* clock.now()
})

// TODO: provide a fake Clock that always returns 0
// and run the program
const test = program.pipe(
  // your code here
)
Reveal solution
import { Effect } from "effect"

class Clock extends Effect.Service<Clock>()("Clock", {
  succeed: {
    now: () => Effect.sync(() => Date.now())
  }
}) {}

const program = Effect.gen(function* () {
  const clock = yield* Clock
  return yield* clock.now()
})

const test = program.pipe(
  Effect.provideService(Clock, {
    now: () => Effect.succeed(0)
  }),
  Effect.runPromise
)
// test resolves to 0 — no real clock involved
Use Context.GenericTag

Create a Random service using Context.GenericTag (not Effect.Service). Define the interface, the tag, a program that uses it, and provide an implementation.

import { Effect, Context } from "effect"

// TODO: Define a Random interface with:
//   next: () => Effect.Effect<number>

// TODO: Create a tag with Context.GenericTag

// TODO: Write a program that calls random.next()

// TODO: Provide an implementation and run it
Reveal solution
import { Effect, Context } from "effect"

interface Random {
  readonly next: () => Effect.Effect<number>
}
const Random = Context.GenericTag<Random>("Random")

const program = Effect.gen(function* () {
  const random = yield* Random
  return yield* random.next()
})
// Type: Effect<number, never, Random>

const result = program.pipe(
  Effect.provideService(Random, {
    next: () => Effect.sync(() => Math.random())
  }),
  Effect.runPromise
)
Common TrapThe R type is like a checklist. The compiler won't let you run an Effect until R = never (all dependencies provided). If you see a type error about missing services, you forgot to provide something — not a bug, a feature.
Read docs →
12
Layers
Composable dependency graphs
60 min

In Step 11 you used Effect.provideService to give one service at a time. That works for simple cases. But real apps have chains: Database needs a ConnectionPool, which needs Config. Layers let you describe these chains once, and Effect wires them for you — in the right order, creating each service once. Key distinction: a Service is WHAT exists (the interface), a Layer is HOW to build it (the recipe). Effect.Service bundles both — that's why you already have layers via MyService.Default without writing any Layer code.

Key InsightA Layer is a recipe, not the service itself. Layer<Out, Err, In> says: 'give me In, and I'll build Out (or fail with Err)'. Effect.Service auto-generates layers — manual Layer.effect/Layer.succeed is for when you need more control.
What to learn
MyService.Default
Auto-generated by Effect.Service. Includes all declared dependencies. Use this 90% of the time — you already have layers without knowing it.
Layer.succeed(tag, value)
Wraps a plain value as a layer. No async, no dependencies. Use for config objects, constants, feature flags.
Layer.effect(tag, effect)
Builds a service from an Effect. Inside the effect, yield* other services to declare dependencies. Most common manual layer.
Layer.merge(a, b)
Bundles two layers into one that provides both. Resolve each layer's dependencies first, then merge — so you're merging ready-to-use layers.
Layer.provide(source)
Plugs one layer's output into another's input. 'LoggerLive needs Config? Here's ConfigLive.' → Layer.provide(ConfigLive).
Effect.provide(layer)
Final step: hands a fully-built layer to your program. Removes those services from R.
Memoization
Layers are created once per provide call by default. If Logger and Database both need Config, Config is built once and shared.
In TypeScript
// TypeScript: you wire dependencies by hand in main()
function createConfig() {
  return { logLevel: "INFO", connection: "postgres://..." }
}
function createLogger(config: Config) {
  return { log: (msg: string) => console.log(`[${config.logLevel}] ${msg}`) }
}
function createDatabase(config: Config, logger: Logger) {
  return { query: (sql: string) => { logger.log(sql); /* ... */ } }
}

// The order matters. Forget a step? Runtime error.
// Two things need config? You pass it twice manually.
const config = createConfig()
const logger = createLogger(config)
const db = createDatabase(config, logger)
With Effect
import { Effect, Context, Layer } from "effect"

// ── 1. Layer.succeed: wrap a plain value ──
// Use for config, constants — things that don't need setup
class Config extends Context.Tag("Config")<Config, {
  readonly logLevel: string
  readonly connection: string
}>() {}

const ConfigLive = Layer.succeed(Config, {
  logLevel: "INFO",
  connection: "postgres://localhost/mydb"
})
// Layer<Config, never, never>
// "I provide Config. I need nothing."

// ── 2. Layer.effect: build a service that needs other services ──
class Logger extends Context.Tag("Logger")<Logger, {
  readonly log: (msg: string) => Effect.Effect<void>
}>() {}

const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config   // ← I need Config to build Logger
    return {
      log: (msg) => Effect.sync(() =>
        console.log(`[${config.logLevel}] ${msg}`)
      )
    }
  })
)
// Layer<Logger, never, Config>
// "I provide Logger. I need Config."

// ── 3. Layer.provide: satisfy a layer's dependencies ──
// LoggerLive needs Config. Give it ConfigLive.
const LoggerResolved = LoggerLive.pipe(Layer.provide(ConfigLive))
// Layer<Logger, never, never>  ← resolved! No more dependencies.

// ── 4. Layer.merge: bundle resolved layers together ──
// Your program needs both Config AND Logger?
// Merge the resolved layers into one:
const AppLive = Layer.merge(ConfigLive, LoggerResolved)
// Layer<Config | Logger, never, never>
// Both resolved. Nothing left to wire.

// ── 5. Putting it all together ──
class Database extends Context.Tag("Database")<Database, {
  readonly query: (sql: string) => Effect.Effect<unknown>
}>() {}

const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    return {
      query: (sql) => Effect.gen(function* () {
        yield* logger.log(`Executing: ${sql}`)
        return { result: `data from ${config.connection}` }
      })
    }
  })
)
// Layer<Database, never, Config | Logger>

// Database needs Config+Logger. AppLive provides both.
const MainLive = DatabaseLive.pipe(Layer.provide(AppLive))
// Layer<Database, never, never>  ← fully resolved!

const program = Effect.gen(function* () {
  const db = yield* Database
  return yield* db.query("SELECT * FROM users")
})

Effect.runPromise(Effect.provide(program, MainLive))
How it works
  Step 1: Each layer is a recipe
  ─────────────────────────────────────────────────────

  ConfigLive                    LoggerLive
  ┌──────────────────┐          ┌──────────────────────┐
  │ Layer.succeed     │          │ Layer.effect          │
  │                   │          │                       │
  │ provides: Config  │          │ provides: Logger      │
  │ needs:    nothing │          │ needs:    Config  ←── │── "I can't build
  └──────────────────┘          └──────────────────────┘     without Config"


  Step 2: Resolve dependencies (Layer.provide)
  ─────────────────────────────────────────────────────

  LoggerLive.pipe(Layer.provide(ConfigLive))
                        │
               "here's the Config you need"
                        │
                        ▼
               LoggerResolved
               ┌──────────────────────┐
               │ provides: Logger      │
               │ needs:    nothing ✓   │
               └──────────────────────┘


  Step 3: Bundle together (Layer.merge)
  ─────────────────────────────────────────────────────

  Layer.merge(ConfigLive, LoggerResolved)
                        │
                        ▼
                    AppLive
               ┌──────────────────────┐
               │ provides: Config      │
               │           Logger      │
               │ needs:    nothing ✓   │
               └──────────────────────┘


  Step 4: Wire to your program (Effect.provide)
  ─────────────────────────────────────────────────────

  program ──── needs Config + Logger
      │
      │  Effect.provide(program, AppLive)
      │
      ▼
  runnable ── needs nothing → Effect.runPromise(runnable)
Practice
Use Effect.Service layers (the easy path)

Most of the time you don't write layers manually. Create a Logger and a Metrics service with Effect.Service, where Metrics depends on Logger. Then provide Metrics.Default to a program.

import { Effect } from "effect"

// TODO: Create Logger with Effect.Service
// It should have: log(msg: string) => Effect.Effect<void>

// TODO: Create Metrics with Effect.Service
// It should have: track(event: string) => Effect.Effect<void>
// Metrics should depend on Logger (use dependencies option)

// TODO: Write a program that uses Metrics, provide Metrics.Default
Reveal solution
import { Effect } from "effect"

class Logger extends Effect.Service<Logger>()("Logger", {
  succeed: {
    log: (msg: string) => Effect.sync(() => console.log(msg))
  }
}) {}

class Metrics extends Effect.Service<Metrics>()("Metrics", {
  effect: Effect.gen(function* () {
    const logger = yield* Logger
    return {
      track: (event: string) => logger.log(`[metric] ${event}`)
    }
  }),
  dependencies: [Logger.Default]
}) {}

const program = Effect.gen(function* () {
  const metrics = yield* Metrics
  yield* metrics.track("page_view")
})

// Metrics.Default includes Logger automatically
Effect.runPromise(program.pipe(Effect.provide(Metrics.Default)))
Build a layer with Layer.succeed

Create a Config service using Context.Tag and a ConfigLive layer using Layer.succeed. Config has a port (number) and host (string).

import { Effect, Context, Layer } from "effect"

// TODO: Define Config with Context.Tag
// TODO: Create ConfigLive with Layer.succeed
Reveal solution
import { Effect, Context, Layer } from "effect"

class Config extends Context.Tag("Config")<Config, {
  readonly port: number
  readonly host: string
}>() {}

const ConfigLive = Layer.succeed(Config, {
  port: 3000,
  host: "localhost"
})
// Layer<Config, never, never>
Wire a dependent layer with Layer.effect

Given Config below, create a Server service that depends on Config. Use Layer.effect. Then use Layer.provide to plug ConfigLive into ServerLive.

import { Effect, Context, Layer } from "effect"

class Config extends Context.Tag("Config")<Config, {
  readonly port: number
  readonly host: string
}>() {}

const ConfigLive = Layer.succeed(Config, {
  port: 3000,
  host: "localhost"
})

// TODO: Define Server service (with a start() method returning Effect<void>)
// TODO: Create ServerLive with Layer.effect — yield* Config inside
// TODO: Use Layer.provide to give ConfigLive to ServerLive
Reveal solution
import { Effect, Context, Layer } from "effect"

class Config extends Context.Tag("Config")<Config, {
  readonly port: number
  readonly host: string
}>() {}

const ConfigLive = Layer.succeed(Config, {
  port: 3000,
  host: "localhost"
})

class Server extends Context.Tag("Server")<Server, {
  readonly start: () => Effect.Effect<void>
}>() {}

const ServerLive = Layer.effect(
  Server,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      start: () => Effect.sync(() =>
        console.log(`Listening on ${config.host}:${config.port}`)
      )
    }
  })
)
// Layer<Server, never, Config> — needs Config

// Plug ConfigLive into ServerLive:
const ServerResolved = ServerLive.pipe(Layer.provide(ConfigLive))
// Layer<Server, never, never> — fully resolved!
Merge two layers together

You have ConfigLive and LoggerLive (which depends on Config). Your program needs both Config and Logger. First resolve LoggerLive's dependency, then merge both resolved layers.

import { Effect, Context, Layer } from "effect"

class Config extends Context.Tag("Config")<Config, {
  readonly env: string
}>() {}
const ConfigLive = Layer.succeed(Config, { env: "production" })

class Logger extends Context.Tag("Logger")<Logger, {
  readonly log: (msg: string) => Effect.Effect<void>
}>() {}
const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
  const config = yield* Config
  return { log: (msg) => Effect.sync(() => console.log(`[${config.env}] ${msg}`)) }
}))

// TODO: 1. Resolve LoggerLive by providing ConfigLive to it
// TODO: 2. Merge ConfigLive and the resolved Logger layer
// TODO: 3. Provide AppLive to the program

const program = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.log("hello")
})
Reveal solution
import { Effect, Context, Layer } from "effect"

class Config extends Context.Tag("Config")<Config, {
  readonly env: string
}>() {}
const ConfigLive = Layer.succeed(Config, { env: "production" })

class Logger extends Context.Tag("Logger")<Logger, {
  readonly log: (msg: string) => Effect.Effect<void>
}>() {}
const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
  const config = yield* Config
  return { log: (msg) => Effect.sync(() => console.log(`[${config.env}] ${msg}`)) }
}))

// 1. Resolve LoggerLive's dependency on Config
const LoggerResolved = LoggerLive.pipe(Layer.provide(ConfigLive))
// Layer<Logger, never, never>  ← no more dependencies

// 2. Merge two resolved layers
const AppLive = Layer.merge(ConfigLive, LoggerResolved)
// Layer<Config | Logger, never, never>  ← both ready!

const program = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.log("hello")
})

Effect.runPromise(Effect.provide(program, AppLive))
Common TrapYou don't always need manual layers. If you defined services with Effect.Service and its dependencies option, just use MyService.Default. Manual Layer.effect/Layer.merge is for advanced cases — don't reach for it by default.
Read docs →
Error HandlingResource Management