Layers
Describe, compose and replace Service providers.
A Layer is a declarative collection of providers. It stores how a Service can be acquired and (optionally) released; it does not acquire instances while the Layer is being built.
Layer.empty
Use Layer.empty for an explicitly empty application environment. It is a
stable, immutable, provider-free Layer, so it can be reused as the neutral
value in composition:
const EmptyLive = Layer.empty
const ApplicationLive = Layer.merge(EmptyLive, InfrastructureLive)
const runtime = await Runtime.make(EmptyLive)
await runtime.run(() => 'no Services required')Layer.empty has the exact public type Layer<never, never> and does not
create providers or requirements.
Layer.make
With no acquire callback, the Service must be constructible without arguments:
class Clock extends Service<Clock>()('Clock') {
now() {
return new Date()
}
}
const ClockLive = Layer.make(Clock)Pass a callback when construction needs configuration or asynchronous setup:
const DatabaseLive = Layer.make(Database, async () => {
const pool = await createPool(process.env.DATABASE_URL!)
return new Database(pool)
})The callback is lazy and accepts the marker-free Service.Contract<Database>
shape. The Layer provides the branded Database instance type; no identity
property is added at runtime.
Layer.succeed
Use succeed for a value that already exists:
const testClock = Clock.of({
now: () => new Date('2025-01-01T00:00:00.000Z')
})
const ClockTest = Layer.succeed(Clock, testClock)The exact object is returned whenever the backend resolves the Service. The Layer does not own it unless you wrap its lifetime separately.
Layer.scoped
scoped adds a Runtime-root release callback. The callback receives the
instance and final ScopeOutcome; callbacks that only accept the instance
remain compatible:
const DatabaseLive = Layer.scoped(
Database,
() => Database.connect(),
(database, outcome) => database.close(outcome)
)The resource is acquired lazily on first resolution, stays alive across
runtime.run calls, and is released when the Runtime root closes. Use
scopedGen when acquisition also needs contextual Services.
Layer.scopedDisposable
For a Service whose implementation already exposes a JavaScript disposal
protocol, scopedDisposable removes the repeated release callback:
const DatabaseLive = Layer.scopedDisposable(Database, () => Database.connect())The provider must return a disposable Service.Contract at the type boundary.
Symbol.asyncDispose wins when both protocols exist. The Runtime root owns the
release, so the resource remains available across executions and the DI backend
only registers and resolves it.
Lifecycle-only Layers
Use Layer.scopedDiscard when a component owns startup and shutdown work but
does not need to be resolved as a Service:
const PollerLive = Layer.scopedDiscard(
() => startPoller(),
(poller, outcome) => poller.stop(outcome)
)Lifecycle-only Layers provide never, but their contextual requirements remain
part of Layer completeness:
const PollerLive = Layer.scopedDiscard(
async function* () {
const config = yield* Config
return startPoller(config)
},
(poller, outcome) => poller.stop(outcome)
)
const ApplicationLive = Layer.complete(Layer.merge(ConfigLive, PollerLive))Runtime activates lifecycle entries after all providers are registered and
after requested warmup. Successful entries are released by the Runtime root
Scope in reverse activation order. If activation fails, earlier entries are
rolled back before the failure is propagated. Use Layer.scopedDiscardGen when
you want to make the contextual generator overload explicit.
For a startup Effect.Program, use Layer.effectDiscard:
const MetricsLive = Layer.effectDiscard(
Effect.fn(async function* () {
const registry = yield* MetricsRegistry
registry.register(defaultMetrics)
return Result.ok(undefined)
})
)effectDiscard requires a void success and never failure channel. Typed
failures must be handled before the program is passed to this constructor.
Layer.gen and Layer.scopedGen
Provider factories can request contextual Services during acquisition:
const RepositoryLive = Layer.gen(UserRepository, async function* () {
const database = yield* Database
return new UserRepository(database)
})Add outcome-aware cleanup with scopedGen:
const RepositoryLive = Layer.scopedGen(
UserRepository,
async function* () {
const database = yield* Database
return new UserRepository(database)
},
(repository, outcome) => repository.close(outcome)
)The generator can yield only Service requirements. A different yielded value
produces LayerGeneratorYieldError at runtime. The generator's requirements
are included in the Layer completeness contract.
Layer.alias
Expose one Service implementation through another compatible port without constructing a second object:
class SqlUserRepository extends Service<SqlUserRepository>()('SqlUserRepository') {
findById(id: string): string {
return `sql:${id}`
}
}
class UserRepository extends Service<UserRepository>()('UserRepository') {
declare findById: SqlUserRepository['findById']
}
const SqlLive = Layer.make(SqlUserRepository)
const UserRepositoryPort = Layer.alias({
from: SqlUserRepository,
to: UserRepository
})
const ApplicationLive = Layer.merge(Layer.empty, SqlLive, UserRepositoryPort)The alias lazily resolves from, returns that exact instance, and provides it
under to; it does not clone, proxy, or mutate the implementation. The source
must satisfy Service.Contract<UserRepository> at compile time. The alias itself
requires the source Service, so composing it with SqlLive leaves no missing
requirements. Missing sources, duplicate tags, and circular aliases use the
same Layer and Runtime diagnostics as other providers.
Layer.merge
Merge distinct providers into an application environment:
const InfrastructureLive = Layer.merge(DatabaseLive, ClockLive)
const ApplicationLive = Layer.merge(InfrastructureLive, UserRepositoryLive, UserServiceLive)merge rejects duplicate tags. This is intentional: silently choosing one
provider makes composition order a hidden dependency. The failure is either:
DuplicateServiceErrorwhen the same constructor is registered twice;ServiceTagCollisionErrorwhen two constructors claim the same tag.
Layer.override
Replacement is explicit:
const AppTest = Layer.override(
ApplicationLive,
Layer.succeed(Database, fakeDatabase),
Layer.succeed(Clock, testClock)
)Overrides are applied left to right; the last compatible provider for a tag
wins. Compatibility is bidirectional: the two marker-free instance contracts
must extend each other. An incompatible same-tag replacement is rejected at
Layer.override, before it can cross a Runtime boundary.
A merge describes a complete environment. Rejecting duplicates prevents accidental replacement
and makes intentional test or tenant substitutions visible in code review. Use override for
replacement.
No. Layer creation stores provider callbacks. Backends acquire providers lazily on first resolution and cache them according to backend behavior.
Yes. Use Layer.gen or Layer.scopedGen and yield* the dependency. The requirement is
inferred and the Runtime checks that it is supplied.
Layer type helpers
The public Layer contract has exactly two channels:
import type { Layer } from 'better-effect'
const RepositoryLive = Layer.gen(UserRepository, async function* () {
const database = yield* Database
return new UserRepository(database)
})
// Layer<UserRepository, Database>
const ApplicationLive = Layer.merge(Layer.make(Database), RepositoryLive)
// Layer<Database | UserRepository, never>
type Provided = Layer.Provided<typeof ApplicationLive>
// Database | UserRepository
type Required = Layer.Required<typeof ApplicationLive>
// never
type Missing = Layer.Missing<typeof ApplicationLive>
// never
type Complete = Layer.Complete<typeof ApplicationLive>Mark the composition root explicitly when you want missing requirements to be reported at that boundary:
const AppLive = Layer.complete(Layer.merge(DatabaseLive, RepositoryLive))Layer.complete is a runtime identity function. Its type checks the full
composition and exposes missing Services through Layer.Missing<typeof AppLive>.
Required is the external environment left after composition, not a raw union
of every provider's dependencies. For database ownership and borrowed native
Kysely instances, see the Kysely integration. Inferred Layers retain private provenance so
a precise override can remove only the replaced provider's requirements:
const AppChecked = Layer.merge(Layer.make(Database), RepositoryLive) satisfies Layer<
Database | UserRepository,
never
>Prefer inference or satisfies when later overrides should remain precise. A
normal : annotation is safe but intentionally erases per-provider provenance,
so later overrides retain requirements conservatively. Layer.Any is the
explicit unchecked generic boundary. Bare Layers, partial-any channels and
concrete Layer unions must be narrowed or completed before composition or
Runtime creation.