better-effect
Core concepts

Runtime

Provision a Layer, execute programs and shut down gracefully.

Runtime is the managed execution boundary. It connects a complete Layer to a backend, owns the Layer's root Scope, and checks each program against the Services that the Layer provides. Without configuration it creates a fresh MapLayerBackend. The published Runtime entrypoint is officially supported on Node.js and Bun; CI uses the latest Bun release by default and the current Node.js LTS for interoperability smoke tests.

Long-lived Runtime

const runtime = await Runtime.make(AppLive)

const result = await runtime.run(loadDashboard)

await runtime.dispose()

The Runtime is designed for Node.js and Bun applications that execute many programs against one environment. Layer-scoped resources remain alive between calls to run.

Runtime also implements JavaScript's async disposal protocol:

await using runtime = await Runtime.make(AppLive)

const result = await runtime.run(loadDashboard)

For a callback-scoped Runtime, use Runtime.use:

const result = await Runtime.use(AppLive, (runtime) => runtime.run(loadDashboard))

Both forms dispose the Runtime after the work settles.

Non-owning executor capability

Framework adapters and other long-lived components can capture a restricted executor view from a Runtime execution:

const captureExecutor = Effect.fn(async function* () {
  const executor = yield* Runtime.executor<Database>()
  return Result.ok(executor)
})

const executor = (await runtime.run(captureExecutor)).unwrap()
await executor.run(loadDashboard)

Runtime.Executor<R> exposes only run and runWith. Each call creates a new child execution on the same Runtime root and preserves the Runtime's resolver, signals, observers, metadata and cleanup semantics. Capturing it from a request does not capture that request's Scope or request-local Services, and the executor cannot dispose, warm up or inspect the owning Runtime. Ordinary application code can continue using runtime.run directly.

Warmup and observability

Layer providers are lazy by default. Servers that need configuration failures before accepting requests can resolve every provider during startup:

const runtime = await Runtime.make(AppLive, { warmup: true })

The same check can be requested later with await runtime.warmup(). A warmup failure reports the failing Service and resolution path, closes resources that were already acquired, disposes the backend and rejects the Runtime. No partially warmed Runtime is returned by Runtime.make.

Runtime observers are optional and dependency-free. Hooks are best effort; observer failures never change execution or cleanup results. Start and end execution events share a collision-resistant executionId and include the optional Program name and readonly attributes:

const runtime = await Runtime.make(AppLive, {
  observers: [
    {
      onServiceResolve: (event) => trace('resolve', event.service.serviceTag),
      onServiceAcquire: (event) => trace('acquire', event.service.serviceTag),
      onExecutionStart: ({ executionId, name }) => trace('execution.start', { executionId, name }),
      onExecutionEnd: ({ executionId, name, outcome, durationMs }) =>
        metrics.observe('program.duration_ms', durationMs, {
          executionId,
          name,
          outcome: outcome.status
        }),
      onResourceRelease: (event) => trace('release', event.service.serviceTag)
    }
  ]
})

const result = await runtime.run(loadDashboard, {
  attributes: { requestId, userId }
})

durationMs uses the host monotonic clock and includes Program plus execution Scope cleanup. Attributes are shallow-copied per run and the Runtime never serializes or inspects their values. Do not attach secrets, large mutable objects, or sensitive data that an observer might log or use as metric labels.

An integration package can translate these events to OpenTelemetry or another telemetry system without adding that dependency to better-effect.

For a deterministic startup graph, use the public testing observer with Layer warmup:

import { Runtime } from 'better-effect'
import { RuntimeGraphObserver } from 'better-effect/testing'

const graph = RuntimeGraphObserver.make({ rootLabel: 'Runtime' })
const runtime = await Runtime.make(AppLive, {
  warmup: true,
  observers: [graph]
})

console.log(graph.toJSON())
console.log(graph.toMermaid())
await runtime.dispose()

The graph reflects observed resolution paths, so lazy Services do not appear until they are resolved. Snapshots are sorted, detached and immutable, and keep only literal Service tags and counters; instances, scopes, causes and execution attributes are not serialized. Call graph.clear() before a later diagnostic session.

Runtime inspection

For coarse, synchronous diagnostics, call runtime.inspect():

const inspection = runtime.inspect()
// {
//   state: 'active',
//   warmup: 'idle',
//   activeExecutions: 0,
//   executions: [],
//   services: ['Database', 'UserRepository'],
//   shutdownSignalAborted: false
// }

The snapshot and its arrays are detached and immutable. It contains only public Service tags and execution IDs, optional names and start timestamps. Execution entries remain present until execution Scope cleanup settles. The warmup field is idle, running, completed or failed; the lifecycle state is active, disposing or disposed. shutdownSignalAborted reports the Runtime's cooperative shutdown signal, when disposal has requested cancellation.

Inspection is diagnostic information, not a lock, synchronization primitive or readiness guarantee. It does not resolve Services, warm the Runtime, fork a Scope, invoke observers, expose providers or instances, or cancel/force-stop executions. Use dispose() and cooperative AbortSignal handling for lifecycle coordination.

Cooperative cancellation

Runtime cancellation is based on AbortSignal; it does not introduce fibers or a scheduler. Pass a signal to one execution:

const result = await runtime.run(handleRequest, {
  signal: request.signal
})

Programs can read the same linked signal through the standard yieldable:

const handleRequest = Effect.fn(async function* () {
  const signal = yield* CurrentAbortSignal
  return Result.ok(await fetchData({ signal }))
})

The Runtime also exposes a graceful shutdown policy. It waits for active work for the grace period, then aborts their linked signals when requested:

await runtime.dispose({
  gracePeriod: 5_000,
  abortAfterGracePeriod: true
})

Cancellation remains cooperative: an execution that ignores its signal can still keep disposal open until it settles.

Service resolution, Scope access and acquisition paths share one RuntimeContext. The published Runtime entrypoint is officially supported on Node.js and Bun, where AsyncLocalStorage is the default context storage. The better-effect/runtime/explicit subpath provides ExplicitRuntimeContextStorage as a manually managed, sequential strategy only when the package entrypoint and host can load it. One instance supports one non-overlapping async flow at a time and rejects concurrent overlap. Use the default Node/Bun storage for concurrent executions:

import { ExplicitRuntimeContextStorage } from 'better-effect/runtime/explicit'

const runtime = await Runtime.make(AppLive, {
  contextStorage: new ExplicitRuntimeContextStorage()
})

Using the explicit strategy does not make the package generally usable in browsers, Deno, Cloudflare Workers, or other non-Node hosts; separate explicit instances are not a general concurrent-isolation strategy.

One-shot Runtime

For a command, migration or script, use Runtime.run:

Database integrations that forward cancellation through a native query terminal can use the Kysely integration.

const migrate = Effect.fn(async function* () {
  const database = yield* Database
  const migrated = yield* Result.await(database.migrate())

  return Result.ok(migrated)
})

const result = await Runtime.run(AppLive, migrate)

The one-shot form creates the Runtime, runs exactly one program, closes its execution and root Scopes with the final outcome, and always attempts backend cleanup. A successful program can therefore still expose a shutdown cleanup failure; a failed program preserves its exact primary failure.

Node.js and Bun CLI entrypoint

Use the Node/Bun-only better-effect/node subpath when a process owns the application Runtime. NodeRuntime.runMain installs no listeners at import time, links one execution signal, and removes every configured listener in finally:

import { Result } from 'better-result'
import { NodeRuntime } from 'better-effect/node'

const main = Effect.fn(async function* () {
  const signal = yield* CurrentAbortSignal
  const value = yield* Result.await(runMigration({ signal }))

  return Result.ok(value)
})

await NodeRuntime.runMain(AppLive, main, {
  signals: ['SIGINT', 'SIGTERM'],
  onFailure: (error) => {
    console.error(error)
    return 1
  },
  onSuccess: () => 0,
  onDefect: (cause) => {
    console.error(cause)
    return 1
  }
})

The first configured SIGINT or SIGTERM immediately aborts CurrentAbortSignal and begins graceful Runtime disposal. Runtime disposal waits cooperatively for the main execution; repeated signals are ignored. The Node boundary has no second grace-period policy, so use Runtime.dispose directly when a managed Runtime needs one. Result.err is passed to onFailure (or exit code 1 by default), while thrown or rejected defects remain rejected and are passed to onDefect; cleanup-only failures go to onCleanupFailure and a cleanup failure after success is still non-zero. The helper sets process.exitCode and never calls process.exit(). Its overloads retain the Layer completeness and Program Service-requirement checks of Runtime.run.

Migrating manual signal handlers

Replace a hand-written process.once/runtime.dispose pair:

process.once('SIGTERM', () => void shutdown())
process.once('SIGINT', () => void shutdown())

with a main Program that waits for CurrentAbortSignal and one boundary call:

const main = Effect.fn(async function* () {
  const signal = yield* CurrentAbortSignal
  const server = startServer({ signal })

  await new Promise<void>((resolve) => {
    signal.addEventListener('abort', () => resolve(), { once: true })
  })
  await server.stop()

  return Result.ok(undefined)
})

await NodeRuntime.runMain(AppLive, main)

Do not install a second signal handler or call process.exit; pass the linked signal to request I/O and let the Runtime wait for active executions.

Worker entrypoints

Workers do not need process signal listeners. Use signals: [] and bridge a parent message to an AbortController instead:

import { parentPort } from 'node:worker_threads'
import { NodeRuntime } from 'better-effect/node'

const controller = new AbortController()
const onMessage = (message: unknown) => {
  if (message === 'shutdown') controller.abort('parent shutdown')
}
parentPort?.on('message', onMessage)

try {
  const result = await NodeRuntime.runMain(Layer.empty, workerMain, {
    signals: [],
    signal: controller.signal
  })
  parentPort?.postMessage(result)
} finally {
  parentPort?.off('message', onMessage)
}

The worker owns its own Runtime and cleanup; the parent remains responsible for sending the shutdown message and terminating the worker after its result is reported.

Layers per execution

Use runWith when a Layer belongs to one execution instead of to the shared Runtime root. The request Layer can depend on root Services, and its providers are resolved through an overlay that is discarded when the execution Scope closes:

const RequestLive = Layer.succeed(RequestContext, RequestContext.of({ requestId, userId }))

const handleRequest = Effect.fn(async function* () {
  const request = yield* RequestContext
  return Result.ok(await application.handle(request))
})

const result = await runtime.runWith(RequestLive, handleRequest)

runWith checks the request Layer's external requirements against the root environment and checks the program against the union of root and request Services. Layer.scoped and Layer.scopedGen providers registered there are released at the end of that execution, while root Layer resources remain shared. A request provider with a compatible existing tag also acts as a request-local override without mutating the root backend.

Typed execution boundaries

Every typed Runtime boundary validates the program's final Effect.Requirements against Layer.Provided<typeof AppLive>:

const AppLive = Layer.succeed(Database, database)

const needsCache = Effect.fn(async function* () {
  const cache = yield* Cache
  return Result.ok(await cache.get('key'))
})

const runtime = await Runtime.make(AppLive)

await runtime.run(needsCache)
// TypeScript error includes MissingDependencies<Cache>.

The Runtime and its inferred Provided union retain this information. A Layer has exactly two public channels: Layer<Provided, Required>. Required contains only dependencies still external after composition. Prefer inferred Layers or satisfies Layer<Provided, Required> when naming a composition root; a normal annotation is safe but erases precise per-provider provenance and makes later overrides conservative.

Concrete Layer<A> | Layer<B> values are rejected at Runtime boundaries because a runtime branch cannot guarantee both environments. Narrow the branch first. Layer.Any is the explicit unchecked boundary; bare Layers and partial-any shapes are not treated as unchecked.

Prefer Runtime.For when naming a Runtime from a concrete Layer:

type AppRuntime = Runtime.For<typeof AppLive>
// Runtime<Database>

export function createServer(runtime: AppRuntime) {
  return {
    handle: () => runtime.run(() => needsDatabase)
  }
}

RuntimeFor<typeof AppLive> remains an equivalent compatibility spelling. An unparameterized Runtime intentionally erases its environment precision and is an unchecked escape hatch.

Execution Scopes

Every runtime.run creates a child Scope of the Runtime root. The child remains open until the program settles, including during re-entrant disposal:

Runtime root
├── Database from Layer.scoped
└── run() child
    ├── request body from Effect.acquireRelease
    └── temporary file from Effect.add

The execution outcome is classified only at the Runtime boundary and only nominal better-result values are inspected:

  • a plain value is success, including an ordinary { status: 'error' } domain value;
  • a nominal Result.ok is success;
  • a nominal Result.err is failure with its error as the cause;
  • a thrown or rejected value is failure with that exact cause.

Intermediate Results do not close a Scope or change its outcome.

Graceful disposal

runtime.dispose() is idempotent and shared by concurrent callers. Its order is deliberate:

  1. Transition to disposing and reject new run calls.
  2. Wait for the snapshot of active executions.
  3. Optionally abort their signals after the configured grace period.
  4. Close the Runtime root Scope.
  5. Dispose the backend last.

An execution failure never prevents root or backend cleanup. A long-lived Runtime closes its root with success regardless of earlier execution outcomes; the one-shot form closes with the complete final outcome.

Cleanup diagnostics

Pass onCleanupFailure to observe aggregated cleanup failures without changing the primary program result:

const runtime = await Runtime.make(AppLive, {
  onCleanupFailure: async (diagnostic) => {
    console.error('cleanup failed', diagnostic.error)
  }
})

Provide a custom adapter through the same options object:

const runtime = await Runtime.make(AppLive, {
  backend: new ItiLayerBackend(),
  onCleanupFailure: reportCleanupFailure
})

Observers are best effort. If an observer itself throws, that observer failure is ignored. The precedence is:

program failure > cleanup failure > program success

Runtime errors

  • LayerRegistrationError: a backend could not register a provider; partial construction is cleaned up and the registration cause remains primary.
  • LayerDisposeError: root and/or backend cleanup failed.
  • ServiceNotFoundError: the backend has no provider for a requested tag.
  • CircularDependencyError: Service acquisition re-entered a tag already in the current resolution path.
  • ServiceAcquisitionError: a provider failed while being acquired; inspect service, resolutionPath and cause.
  • RuntimeHandleDisposedError (internal): a disposed handle rejects new work.

On this page