better-effect

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.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 its return type must match the Service instance contract.

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 while retaining the simple, compatibility-friendly (instance) => ... shape:

const DatabaseLive = Layer.scoped(
  Database,
  () => Database.connect(),
  (database) => database.close()
)

The resource is acquired lazily on first resolution, stays alive across runtime.run calls, and is released when the Runtime root closes. The callback does not receive a ScopeOutcome; use scopedGen when it needs one.

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.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 instance contracts must extend each other. An incompatible same-tag replacement remains visible in the type as __betterEffectLayerOverrideCollisions and cannot cross a complete Runtime boundary.

Layer type helpers

The public type helpers make application boundaries explicit:

import type { LayerMissing, LayerProvided, LayerRawRequired, LayerSpecs } from 'better-effect'

type Provided = LayerProvided<typeof ApplicationLive>
type RequiredDuringAcquire = LayerRawRequired<typeof ApplicationLive>
type Missing = LayerMissing<typeof ApplicationLive>
type Specs = LayerSpecs<typeof ApplicationLive>

Usually you do not need to annotate these types. They are useful for library authors, composition roots and diagnostics that intentionally expose a Layer's contract.

On this page