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.

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.