Layers & Wiring
The constructor family
Constructors, distinguished by how construction is qualified. Do not collapse the first three into a single value-or-function overload.
| constructor | sync/async | can fail | needs context | teardown |
|---|---|---|---|---|
Layer.value(tag, service) | ready value | no | no | no |
Layer.factory(tag, f) | sync | no | yes | no |
Layer.make(tag, f) | sync or async | yes | yes | no |
Layer.acquireRelease(tag, acquire, release) | sync or async | yes | yes | yes |
// value — an already-built service.
const LoggerLive = Layer.value(Logger, { log: (m) => console.log(m) });
// factory — built synchronously and infallibly from the context.
const RepoLive = Layer.factory(OrderRepository, (ctx: Context<Database>) => {
const db = ctx.get(Database);
return { findById: (id) => /* … */ };
});
// make — may fail and/or be async; returns a Result or AsyncResult.
const ConfigLive = Layer.make(AppConfig, () =>
ok ? Ok({ dbUrl }) : Err(new ConfigError({ reason })),
);Inference: you rarely annotate
Layer.make<Self, Service, E, Needs> infers both channels:
Serviceis pinned by the tag, so the value shape never needs annotating.Eis inferred from whatfreturns — returningErr(new ConfigError(...))makesE = ConfigErroron its own.
const ConfigLive = Layer.make(AppConfig, () =>
cond ? Ok({ dbUrl }) : Err(new ConfigError({ reason })),
);
// ^? Layer<AppConfig, ConfigError, never> — inferred, no annotationAnnotate only to declare a failure the body doesn't currently produce (a path that returns only Ok today but is contractually fallible — inference would give E = never), or for a throw-only body.
Qualify at the boundary
Async / fallible work enters only through Layer.make. A raw Promise must never enter a combinator — an unqualified rejection would silently become a Defect instead of a modeled error. Re-enter the typed world with fromPromise / fromSafePromise, exactly as in unthrown:
const DatabaseLive = Layer.make(Database, (ctx: Context<AppConfig>) => {
const { dbUrl } = ctx.get(AppConfig);
return fromPromise(connectDb(dbUrl), () => new ConnectionError({ url: dbUrl }));
});Combinators
Layer.provideTo — discharge a requirement
Feed one layer into another. provideTo(self, dep) builds dep first; on success self builds with the merged context. Errors union; the shared requirement is subtracted from Needs (Exclude<N, P2> | N2).
const DatabaseWired = Layer.provideTo(DatabaseLive, ConfigLive);
// ^? Layer<Database, ConnectionError | ConfigError, never>Layer.merge — combine independent layers
Variadic: combine any number of independent layers in one call. They build in parallel (allAsync); the first Err short-circuits and a thrown value becomes a Defect. Provides, errors, and requirements all union across every layer.
const AppLayer = Layer.merge(LoggerLive, RepoWired, DatabaseWired);
// ^? Layer<Logger | OrderRepository | Database, ConnectionError | ConfigError, never>Layer.wire — assemble a set automatically
merge builds its layers independently (they don't feed each other); provideTo threads one into another by hand. Layer.wire does the threading for you: each layer's requirements may be satisfied by any other layer in the set, so you list them in any order and wire resolves the dependency graph.
// before — hand-threaded, order matters
const DatabaseWired = Layer.provideTo(DatabaseLive, ConfigLive);
const RepoWired = Layer.provideTo(OrderRepoLive, DatabaseWired);
const AppLayer = Layer.merge(LoggerLive, RepoWired, DatabaseWired);
// after — one call, any order
const AppLayer = Layer.wire(OrderRepoLive, LoggerLive, ConfigLive, DatabaseLive);
// ^? Layer<OrderRepository | Logger | AppConfig | Database, ConnectionError | ConfigError, never>wire provides the union of every service (intermediates included), unions the errors, and its remaining Needs are exactly the services no layer in the set provides. So a self-contained set is Needs = never (ready to build), and a missing dependency stays in the type — build names it as a compile error.
Runtime & limits
wire builds each layer once a round against the services built so far (a layer that reads a not-yet-built dependency is deferred), so order doesn't matter. A first Err short-circuits; a genuine dependency cycle surfaces as a Defect at runtime (the types can't catch cycles). Pass the individual *Live layers — not pre-composed ones.
Layer.build — run a fully-wired layer
Callable only once Needs is never. The AsyncResult still carries E, since construction itself may fail — you handle it at the edge.
const result = await Layer.build(AppLayer);
// ^? Result<Context<Logger | OrderRepository | Database>, ConnectionError | ConfigError>Layer.build vs the build member
Layer.build(layer) is the runner. It's distinct from the build member on the Layer type, which is a property (not a method) on purpose — that's what gives Needs its strict contravariance, making a missing dependency a real compile error.
A build also memoizes: a layer shared across branches constructs once. For resources that need teardown, see Resources & Scopes.
Layer.acquireRelease + Layer.scoped — resources
Layer.acquireRelease(tag, acquire, release) builds a service and registers its release. Layer.scoped(layer, use) builds, runs use, then releases every resource in reverse order (LIFO) — even if use fails.
const PoolLive = Layer.acquireRelease(
Pool,
() => fromPromise(openPool(), (cause) => new PoolError({ cause })),
(pool) => pool.end(),
);
// ^? Layer<Pool, PoolError, Scope>
const result = await Layer.scoped(PoolLive, (ctx) => useThePool(ctx.get(Pool)));
// pool released here, whatever the outcomeacquireRelease puts a phantom Scope in the layer's requirements, so Layer.buildrejects a resource graph at compile time — you're forced to use Layer.scoped. Full details in Resources & Scopes.