Skip to content

Scope and finalizers

A Scope is the lifetime of one or more resources. While a scope is open you can attach finalizers to it; when the scope is closed, those finalizers run. A finalizer is just an Effect that performs cleanup. This is the foundation every other resource-management API in Effect is built on.

The high-level way to register a finalizer is Effect.addFinalizer. It adds the finalizer to the current scope and gives you the Exit value describing how the scope closed, so cleanup can react to success vs. failure.

import { Effect, Exit, Console } from "effect"
const program = Effect.gen(function*() {
// Register two finalizers. Each receives the `Exit` the scope closed with.
yield* Effect.addFinalizer((exit) =>
Console.log(`finalizer 1 — exit was ${exit._tag}`)
)
yield* Effect.addFinalizer((exit) =>
Console.log(`finalizer 2 — exit was ${exit._tag}`)
)
yield* Console.log("doing work...")
return "result"
})
// `Effect.scoped` opens a scope, runs `program`, then closes the scope —
// running the finalizers. It also discharges the `Scope` requirement.
Effect.runPromise(Effect.scoped(program))
/*
Output:
doing work...
finalizer 2 — exit was Success <-- finalizers run in reverse order
finalizer 1 — exit was Success
*/

Two things to internalize from this example:

  • program has type Effect<string, never, Scope>. The Scope in the requirements channel means “I register finalizers and need a scope to run them in.” Effect.scoped provides that scope and removes the requirement.
  • Finalizers run in reverse order of registration, like unwinding a stack.

The point of a finalizer is that it always runs once registered — on success, on failure, and on interruption. Here the effect fails, and the finalizer still fires and can observe the failure through the Exit.

import { Effect, Console } from "effect"
const program = Effect.gen(function*() {
yield* Effect.addFinalizer((exit) =>
Console.log(`cleaning up — exit was ${exit._tag}`)
)
// Fail partway through.
return yield* Effect.fail("boom")
})
Effect.runPromiseExit(Effect.scoped(program)).then(console.log)
/*
Output:
cleaning up — exit was Failure
{ _id: 'Exit', _tag: 'Failure', cause: { ... 'boom' ... } }
*/

Effect.addFinalizer targets the surrounding scope. When you only want cleanup attached to a single effect — no scope in the type — reach for these combinators instead. They mirror try/finally/catch:

  • Effect.ensuring runs a finalizer whether the effect succeeds, fails, or is interrupted. It does not see the result. This is the closest analogue to a finally block.
  • Effect.onExit runs cleanup and receives the full Exit, so it can branch on success vs. failure vs. interruption. The cleanup is uninterruptible.
  • Effect.onError runs cleanup only when the effect fails (including by interruption), receiving the Cause.
import { Effect, Exit, Cause, Console } from "effect"
const work = Console.log("working...").pipe(Effect.as(42))
const program = work.pipe(
// Always runs — good for releasing a lock or logging completion.
Effect.ensuring(Console.log("ensuring: always runs")),
// Sees the outcome — branch on how the effect ended.
Effect.onExit((exit) =>
Console.log(
Exit.isSuccess(exit)
? `onExit: succeeded with ${exit.value}`
: "onExit: did not succeed"
)
),
// Only runs on failure — receives the Cause for diagnostics.
Effect.onError((cause) => Console.log(`onError: ${Cause.pretty(cause)}`))
)
Effect.runPromise(program)
/*
Output:
working...
onExit: succeeded with 42
ensuring: always runs
*/

Scopes compose. Effect.scoped always opens a fresh scope around the effect it wraps, so you can carve out a shorter lifetime in the middle of a longer one. Anything acquired inside the inner scope is released as soon as that inner block finishes, while resources acquired in the outer scope live until the outer block ends.

import { Effect, Console } from "effect"
const resource = (name: string) =>
Effect.acquireRelease(
Console.log(`acquire ${name}`).pipe(Effect.as(name)),
() => Console.log(`release ${name}`)
)
const program = Effect.gen(function*() {
// `outer` lives for the whole `program`.
yield* resource("outer")
// `inner` lives only for this nested scoped block — it is released
// immediately when the block completes, before "after inner".
yield* Effect.scoped(
Effect.gen(function*() {
yield* resource("inner")
yield* Console.log("using inner")
})
)
yield* Console.log("after inner")
})
Effect.runPromise(Effect.scoped(program))
/*
Output:
acquire outer
acquire inner
using inner
release inner <-- inner scope closes here
after inner
release outer <-- outer scope closes when program ends
*/

For most code you never touch a Scope value — Effect.scoped and Layers manage it. When you do need explicit control (for example, handing the same scope to several independent tasks), Effect.scopedWith gives you the scope and lets you register finalizers on it with Scope.addFinalizer.

import { Effect, Scope, Console } from "effect"
const program = Effect.scopedWith((scope) =>
Effect.gen(function*() {
// Attach finalizers to the explicit scope.
yield* Scope.addFinalizer(scope, Console.log("release A"))
yield* Scope.addFinalizer(scope, Console.log("release B"))
yield* Console.log("work")
// When `scopedWith` returns, it closes `scope`, running both finalizers
// in reverse order: "release B", then "release A".
})
)
Effect.runPromise(program)
/*
Output:
work
release B
release A
*/

Now that you understand scopes and finalizers, the next page covers the ergonomic, leak-safe way to define resources: acquire / release.