better-effect
Core concepts

Mental Model

Understand the four boundaries that make better-effect predictable.

An Effect is a value plus requirements

An Effect.gen result is not a class instance or a task scheduler. At runtime it is a Result (or a Promise of a Result). At compile time it additionally records the Services yielded by the generator.

Effect.gen(...) ──► Result<A, E> & { requirements: R }
Effect.fn(...)  ───► Program<A, E, R> ──invoke──► Effect


                     Runtime environment must provide R

The requirement channel is type-only. Service resolution still happens through the active resolver when the generator runs.

When the generator must wait for a Runtime boundary, wrap it with Effect.fn. The factory is lazy and returns a nominal Program function; calling that function inside runtime.run creates the eager Effect in the correct resolver and Scope context:

const program = Effect.fn(async function* () {
  const database = yield* Database
  return Result.ok(await database.query())
})

const result = await runtime.run(program)

A Layer is a recipe, not an instance

Layer.make(Database, () => new Database(config)) stores an acquisition callback. It does not call the callback. A backend registers the provider, and the provider is acquired lazily the first time the Service is resolved.

This distinction keeps startup cheap and makes ownership explicit:

  • Layer.make describes lazy acquisition without a release callback.
  • Layer.succeed describes an already-created value.
  • Layer.scoped describes lazy acquisition plus Runtime-root cleanup.
  • Layer.gen and Layer.scopedGen can request other Services during acquisition.

Runtime is the long-lived owner

A Runtime owns a root Scope. Every runtime.run creates a child Scope. This gives request-local resources a short lifetime while keeping database pools or clients shared for the lifetime of the application.

Runtime root Scope
├── Layer.scoped(Database)
├── Layer.scoped(Cache)
└── execution child Scope
    ├── Effect.acquireRelease(request resource)
    └── Effect.add(already acquired disposable)

When disposal starts, new executions are rejected, active executions finish, the root Scope closes, and the backend is disposed last.

Result remains the control-flow model

Use Result.ok, Result.err and Result.await exactly as you do with better-result. Effect.gen delegates its runtime generator semantics to Result.gen; it only adds Service requirements to the inferred type.

Every Result.gen/Effect.gen generator must finish by returning a Result. Returning a raw value is not a successful completion of the generator contract.

On this page