Skip to content

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.

constructorsync/asynccan failneeds contextteardown
Layer.value(tag, service)ready valuenonono
Layer.factory(tag, f)syncnoyesno
Layer.make(tag, f)sync or asyncyesyesno
Layer.acquireRelease(tag, acquire, release)sync or asyncyesyesyes
ts
// 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:

  • Service is pinned by the tag, so the value shape never needs annotating.
  • E is inferred from what f returns — returning Err(new ConfigError(...)) makes E = ConfigError on its own.
ts
const ConfigLive = Layer.make(AppConfig, () =>
  cond ? Ok({ dbUrl }) : Err(new ConfigError({ reason })),
);
//    ^? Layer<AppConfig, ConfigError, never>   — inferred, no annotation

Annotate 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:

ts
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).

ts
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.

ts
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.

ts
// 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.

ts
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.

ts
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 outcome

acquireRelease 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.

Released under the MIT License.