better-effect
Core concepts

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:

  • DuplicateServiceError when the same constructor is registered twice;
  • ServiceTagCollisionError when 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.

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.

On this page