Skip to content

Layers from Config

Sometimes which implementation to build is itself a decision that requires running an effect — reading an environment variable, querying a discovery service, checking a feature flag. Layer.unwrap takes an Effect<Layer<...>> and flattens it into a plain Layer. You compute the layer inside an effect, then hand it back, and unwrap builds whatever you returned.

import { Config, Context, Effect, Layer, Schema } from "effect"
export class MessageStoreError extends Schema.TaggedErrorClass<MessageStoreError>()(
"MessageStoreError",
{ cause: Schema.Defect }
) {}
export class MessageStore extends Context.Service<MessageStore, {
append(message: string): Effect.Effect<void>
readonly all: Effect.Effect<ReadonlyArray<string>>
}>()("myapp/MessageStore") {
// One concrete implementation: keep messages in memory.
static readonly layerInMemory = Layer.effect(
MessageStore,
Effect.sync(() => {
const messages: Array<string> = []
return MessageStore.of({
append: (message) => Effect.sync(() => { messages.push(message) }),
all: Effect.sync(() => [...messages])
})
})
)
// Another, parameterised by a URL — pretend this opens a network connection.
static readonly layerRemote = (url: URL) =>
Layer.effect(
MessageStore,
Effect.try({
try: () => {
const messages: Array<string> = []
return MessageStore.of({
append: (message) =>
Effect.sync(() => { messages.push(`[${url.host}] ${message}`) }),
all: Effect.sync(() => [...messages])
})
},
catch: (cause) => new MessageStoreError({ cause })
})
)
// `Layer.unwrap` runs this effect once at build time and uses whichever layer
// it returns. The decision logic lives in normal Effect code.
static readonly layer = Layer.unwrap(
Effect.gen(function*() {
const useInMemory = yield* Config.boolean("MESSAGE_STORE_IN_MEMORY").pipe(
Config.withDefault(false)
)
if (useInMemory) {
return MessageStore.layerInMemory
}
// Reads MESSAGE_STORE_URL only when actually needed.
const remoteUrl = yield* Config.url("MESSAGE_STORE_URL")
return MessageStore.layerRemote(remoteUrl)
})
)
}

MessageStore.layer has the type of a regular layer — its callers don’t know or care that the choice was made dynamically. Provide it like any other layer:

import { Effect } from "effect"
import { MessageStore } from "./MessageStore.ts"
const program = Effect.gen(function*() {
const store = yield* MessageStore
yield* store.append("hello")
return yield* store.all
})
Effect.runFork(program.pipe(Effect.provide(MessageStore.layer)))
  • The effect you pass runs once, when the layer is built. Its errors and requirements become the resulting layer’s errors and requirements — so reading config that fails surfaces as a layer build failure, not a silent fallback.
  • The layer it returns is then built normally, with all the usual memoization and scoping. You can return any layer, including ones with their own dependencies.
  • Because the decision is plain Effect code, you can branch on anything an effect can reach: configuration, another service, a clock, a remote lookup.

The type signature makes the propagation precise:

import { Effect, Layer } from "effect"
// Layer.unwrap: <A, E1, R1, E, R>(
// self: Effect<Layer<A, E1, R1>, E, R>
// ) => Layer<A, E | E1, R1 | Exclude<R, Scope.Scope>>

The outer effect’s error channel E is merged with the inner layer’s error channel E1, and the outer effect’s requirements R are merged with the inner layer’s requirements R1. In the example above, the Config.url lookup can fail, so MessageStore.layer carries a ConfigError in its error channel and a ConfigProvider requirement — both inherited from the unwrapped effect.

Reach for Layer.unwrap whenever the shape of your dependency graph depends on runtime information:

  • Environment switches — in-memory vs. remote, stub vs. live, as above.
  • Config-driven wiring — pick a database driver, a cache backend, or a region’s endpoint based on configuration.
  • Feature flags — enable an alternate implementation behind a flag read at startup.

If you only need to read configuration into a single fixed implementation, you don’t need unwrap — just yield* the config inside an ordinary Layer.effect. Reach for unwrap specifically when the config decides which layer to build. See Configuration for the full Config API.

A common pattern: a single service has several concrete backends, and a config value names which one to use. Config.literals restricts the value to a known set and parses it for you, so the branch is exhaustive and typo-safe.

import { Config, Context, Effect, Layer } from "effect"
export class Cache extends Context.Service<Cache, {
readonly get: (key: string) => Effect.Effect<string | undefined>
readonly set: (key: string, value: string) => Effect.Effect<void>
}>()("myapp/Cache") {
static readonly layerMemory = Layer.sync(Cache, () => {
const store = new Map<string, string>()
return Cache.of({
get: (key) => Effect.sync(() => store.get(key)),
set: (key, value) => Effect.sync(() => { store.set(key, value) })
})
})
static readonly layerRedis = (url: URL) =>
Layer.sync(Cache, () => {
// imagine a real Redis client keyed off `url` here
const store = new Map<string, string>()
return Cache.of({
get: (key) => Effect.sync(() => store.get(`${url.host}:${key}`)),
set: (key, value) => Effect.sync(() => { store.set(`${url.host}:${key}`, value) })
})
})
// CACHE_DRIVER is constrained to exactly "memory" | "redis".
static readonly layer = Layer.unwrap(
Effect.gen(function*() {
const driver = yield* Config.literals(["memory", "redis"], "CACHE_DRIVER").pipe(
Config.withDefault("memory" as const)
)
switch (driver) {
case "memory":
return Cache.layerMemory
case "redis": {
const url = yield* Config.url("CACHE_REDIS_URL")
return Cache.layerRedis(url)
}
}
})
)
}

The same idea swaps an entire implementation behind a boolean — handy for running a real integration against live infrastructure or a fake in CI. Because the unwrapped effect can read config, the toggle lives in one place.

import { Config, Context, Effect, Layer } from "effect"
export class Mailer extends Context.Service<Mailer, {
readonly send: (to: string, body: string) => Effect.Effect<void>
}>()("myapp/Mailer") {
// Talks to a real SMTP endpoint (sketched).
static readonly Live = Layer.effect(
Mailer,
Effect.gen(function*() {
const from = yield* Config.string("MAIL_FROM")
return Mailer.of({
send: (to, body) =>
Effect.sync(() => { /* send via SMTP, from `from` */ void [from, to, body] })
})
})
)
// Records nothing, swallows everything — safe for tests.
static readonly Test = Layer.succeed(Mailer)(
Mailer.of({ send: () => Effect.void })
)
static readonly layer = Layer.unwrap(
Effect.gen(function*() {
const useLive = yield* Config.boolean("MAIL_LIVE").pipe(
Config.withDefault(false)
)
return useLive ? Mailer.Live : Mailer.Test
})
)
}

Both defer building a layer, but they differ in what the deferral can see:

  • Layer.unwrap(effect => Layer) defers behind an effect. The factory can read config, pull from other services, or perform any effectful work, and those requirements and errors flow into the resulting layer’s RIn / E. Use it when the choice of layer is computed at build time.
  • Layer.suspend(() => Layer) is a pure thunk — () => Layer<A, E, R>, no effect involved. It simply delays evaluating the factory until the layer is first built, then memoizes the result with normal layer sharing. The resulting layer’s A, E, and R are exactly those of the returned layer; nothing is added.
import { Config, Context, Effect, Layer } from "effect"
class ApiUrl extends Context.Service<ApiUrl, string>()("ApiUrl") {}
// Effectful choice: needs ConfigProvider, can fail with ConfigError.
const fromConfig = Layer.unwrap(
Effect.gen(function*() {
const prod = yield* Config.boolean("PROD").pipe(Config.withDefault(false))
return prod
? Layer.succeed(ApiUrl)("https://api.example.com")
: Layer.succeed(ApiUrl)("http://localhost:3000")
})
)
// Pure choice on a plain value: no requirements, no errors added.
const useProd = true
const fromValue = Layer.suspend(() =>
useProd
? Layer.succeed(ApiUrl)("https://api.example.com")
: Layer.succeed(ApiUrl)("http://localhost:3000")
)

If the only input to the decision is an ordinary value already in scope, prefer Layer.suspend (or even a plain conditional). Reach for Layer.unwrap the moment the decision needs to run an effect.

Layer.suspend is also the tool for mutually-referential layers. Two layers that mention each other in the same module would otherwise be a use-before-init reference error; wrapping one in suspend delays touching the other until build time, after both bindings exist.

import { Context, Layer } from "effect"
class A extends Context.Service<A, string>()("A") {}
class B extends Context.Service<B, string>()("B") {}
// `bLayer` is referenced before it is defined; `suspend` defers the lookup.
const aLayer: Layer.Layer<A> = Layer.suspend(() =>
Layer.succeed(A)("a").pipe(Layer.provideMerge(bLayer))
)
const bLayer: Layer.Layer<B> = Layer.succeed(B)("b")

Flattens an Effect<Layer<A, E1, R1>, E, R> into a Layer<A, E | E1, R1 | Exclude<R, Scope.Scope>>. The effect runs once at build time; its errors and requirements merge into the resulting layer’s. Use it when the choice of layer must be computed effectfully.

import { Effect, Layer, Context } from "effect"
class Greeting extends Context.Service<Greeting, string>()("Greeting") {}
const choose = Effect.succeed(Layer.succeed(Greeting)("hello"))
const layer = Layer.unwrap(choose)
// => Layer<Greeting, never, never> — the effect can't fail and needs nothing

Builds a layer lazily from a pure factory () => Layer<A, E, R>. Evaluation is deferred until first build and then memoized; the result type is exactly the returned layer’s. Use it for value-based choices and to break layer reference cycles.

import { Layer, Context } from "effect"
class Port extends Context.Service<Port, number>()("Port") {}
const layer = Layer.suspend(() => Layer.succeed(Port)(8080))
// => Layer<Port, never, never>

For the broader Config API used to drive these decisions, see Configuration; for composing, providing, and memoizing the resulting layers, see Managing Layers.