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
*/

A value of type Effect<A, E, Scope> is scoped: it has acquired something and registered a finalizer, and it cannot run until something provides a Scope. Effect.scoped is how you discharge that requirement at the boundary of a workflow — it opens a fresh scope, runs the effect, and closes the scope (with the effect’s own Exit) the instant the workflow finishes.

import { Effect, Console } from "effect"
// `db` is scoped: its type is Effect<Db, never, Scope>.
const db = Effect.acquireRelease(
Console.log("open connection").pipe(Effect.as({ query: () => "rows" })),
() => Console.log("close connection")
)
const useDb = Effect.gen(function*() {
const conn = yield* db
yield* Console.log(`query returned: ${conn.query()}`)
})
// useDb : Effect<void, never, Scope>
// `Effect.scoped` provides the scope and removes it from the requirements,
// closing the connection as soon as `useDb` completes.
Effect.runPromise(Effect.scoped(useDb))
/*
Output:
open connection
query returned: rows
close connection
*/

When the resource should outlive a single workflow — for example, a connection pool shared by an entire service — provide the scope at the application boundary instead. Layers hold scoped resources for the lifetime of the layer, which is the usual real-world pattern.

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
*/

These Effect.* combinators are what application code reaches for daily. They operate on the current scope (provided by Effect.scoped or a Layer) without ever forcing you to handle a Scope value by hand.

Registers a finalizer on the current scope. The finalizer receives the Exit the scope closed with, and always runs once registered.

import { Effect, Exit, Console } from "effect"
const program = Effect.gen(function*() {
yield* Effect.addFinalizer((exit) =>
Console.log(`closing — ${Exit.isSuccess(exit) ? "ok" : "error"}`)
)
return "done"
})
Effect.runPromise(Effect.scoped(program)).then(console.log)
// => closing — ok
// => done

Builds a scoped resource from an acquire effect and a release finalizer. Acquisition is run uninterruptibly by default, and the release runs when the surrounding scope closes (receiving the Exit). The result requires Scope.

import { Effect, Console } from "effect"
const file = Effect.acquireRelease(
Console.log("open file").pipe(Effect.as("handle")),
(handle, exit) => Console.log(`close ${handle} (${exit._tag})`)
)
// file : Effect<string, never, Scope>
Effect.runPromise(Effect.scoped(file))
// => open file
// => close handle (Success)

Pass { interruptible: true } as the third argument if acquisition should be interruptible. See acquire / release for the full treatment.

Brackets acquire, use, and release in a single effect — the classic bracket/try-with-resources shape. Unlike acquireRelease, it does not add Scope to the requirements: the resource lives exactly for the use callback.

import { Effect, Console } from "effect"
const program = Effect.acquireUseRelease(
// acquire
Console.log("connect").pipe(Effect.as({ query: () => "rows" })),
// use
(db) => Console.log(`got: ${db.query()}`),
// release — receives the resource and the Exit of `use`
(_db, exit) => Console.log(`disconnect (${exit._tag})`)
)
Effect.runPromise(program)
// => connect
// => got: rows
// => disconnect (Success)

Provides a fresh scope to an effect, runs it, then closes the scope with the effect’s Exit. This is the standard way to discharge a Scope requirement at the edge of a workflow.

import { Effect, Console } from "effect"
const scopedWork = Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("cleanup"))
return 42
})
// scopedWork : Effect<number, never, Scope>
Effect.runPromise(Effect.scoped(scopedWork)).then(console.log)
// => cleanup
// => 42

Like Effect.scoped, but passes the freshly created Scope to a callback so you can register finalizers on it directly. The scope is closed when the callback’s effect completes.

import { Effect, Scope, Console } from "effect"
const program = Effect.scopedWith((scope) =>
Scope.addFinalizer(scope, Console.log("released")).pipe(
Effect.andThen(Console.log("running"))
)
)
Effect.runPromise(program)
// => running
// => released

Accesses the current Scope from context. It has type Effect<Scope, never, Scope> — it both requires and yields the active scope — so use it inside a scoped workflow when you need the scope value itself.

import { Effect, Scope, Console } from "effect"
const program = Effect.gen(function*() {
// Grab the active scope and register a finalizer on it.
const scope = yield* Effect.scope
yield* Scope.addFinalizer(scope, Console.log("done"))
yield* Console.log("work")
})
Effect.runPromise(Effect.scoped(program))
// => work
// => done

Attaches a finalizer to a single effect. It runs on success, failure, or interruption but does not observe the result. No Scope is added to the type.

import { Effect, Console } from "effect"
const program = Console.log("task").pipe(
Effect.ensuring(Console.log("always"))
)
Effect.runPromise(program)
// => task
// => always

Runs cleanup that receives the full Exit of the effect. The cleanup is uninterruptible, so it is safe for teardown that must complete.

import { Effect, Exit, Console } from "effect"
const program = Effect.succeed(1).pipe(
Effect.onExit((exit) => Console.log(`exit: ${exit._tag}`))
)
Effect.runPromise(program)
// => exit: Success

Runs cleanup only when the effect fails (including by interruption), passing the Cause. On success the cleanup is skipped.

import { Effect, Cause, Console } from "effect"
const program = Effect.fail("boom").pipe(
Effect.onError((cause) => Console.log(`failed: ${Cause.pretty(cause)}`))
)
Effect.runPromiseExit(program)
// => failed: Error: boom

The Scope module exposes the primitives the combinators above are built from. You reach for it when you need explicit control over a lifetime boundary — for example wiring infrastructure, sharing one scope across several tasks, or building a resource pool. A Scope has a strategy ("sequential" finalizers run in reverse registration order; "parallel" run concurrently) and a state that is Empty, Open, or Closed.

A Scope is a mutable lifetime boundary: while state._tag === "Open" it accepts finalizers, and closing it runs them. It exposes strategy and state.

import { Effect, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const scope = yield* Scope.make("sequential")
console.log(scope.strategy) // => "sequential"
console.log(scope.state._tag) // => "Empty"
yield* Scope.addFinalizer(scope, Console.log("cleanup"))
console.log(scope.state._tag) // => "Open"
})

A Closeable extends Scope with the ability to be closed via Scope.close or Scope.use. The scope constructors return Closeable scopes; the Scope service tag exposes only the narrower Scope interface.

import { Effect, Exit, Scope } from "effect"
const program = Effect.gen(function*() {
const scope: Scope.Closeable = yield* Scope.make()
yield* Scope.close(scope, Exit.void) // only Closeable scopes can be closed
})

The service tag (and reference) for the active scope in context. Yield it inside a scoped workflow to obtain the current Scope.

import { Effect, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(scope, Console.log("cleanup"))
})
// `Effect.scoped` provides the Scope service.
Effect.runPromise(Effect.scoped(program))
// => cleanup

Creates a fresh Closeable scope as an effect, with an optional finalizer strategy ("sequential" by default).

import { Effect, Exit, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const scope = yield* Scope.make("sequential")
yield* Scope.addFinalizer(scope, Console.log("1"))
yield* Scope.addFinalizer(scope, Console.log("2"))
yield* Scope.close(scope, Exit.void) // sequential => reverse order
})
Effect.runPromise(program)
// => 2
// => 1

Creates a Closeable scope synchronously, outside the Effect runtime. Use only when integrating with non-Effect code that needs a scope immediately.

import { Scope } from "effect"
const scope = Scope.makeUnsafe("parallel")
console.log(scope.strategy) // => "parallel"
console.log(scope.state._tag) // => "Empty"

Provides a concrete Scope to an effect that requires one, removing Scope from its requirements. Unlike use, it does not close the scope afterwards — you remain responsible for closing it.

import { Effect, Exit, Scope, Console } from "effect"
const needsScope = Effect.gen(function*() {
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(scope, Console.log("released"))
yield* Console.log("working")
})
const program = Effect.gen(function*() {
const scope = yield* Scope.make()
yield* Scope.provide(needsScope, scope) // requirement discharged, not closed
yield* Console.log("before close")
yield* Scope.close(scope, Exit.void) // we close it ourselves
})
Effect.runPromise(program)
// => working
// => before close
// => released

Registers a finalizer on a scope that ignores the closing Exit. If the scope is already closed, the finalizer runs immediately.

import { Effect, Exit, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("cleanup"))
yield* Scope.close(scope, Exit.void)
})
Effect.runPromise(program)
// => cleanup

Registers a finalizer that receives the Exit the scope was closed with, so it can branch on success vs. failure. If the scope is already closed, it runs immediately with the stored Exit.

import { Effect, Exit, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const scope = yield* Scope.make()
yield* Scope.addFinalizerExit(scope, (exit) =>
Console.log(`closing with ${exit._tag}`)
)
yield* Scope.close(scope, Exit.fail("boom"))
})
Effect.runPromise(program)
// => closing with Failure

Creates a Closeable child scope registered with a parent. Closing the parent closes the child with the same Exit; closing the child first detaches it from the parent. The child takes its own finalizer strategy.

import { Effect, Exit, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const parent = yield* Scope.make("sequential")
yield* Scope.addFinalizer(parent, Console.log("parent cleanup"))
// Child lifetime is bound to the parent.
const child = yield* Scope.fork(parent, "parallel")
yield* Scope.addFinalizer(child, Console.log("child cleanup"))
// Closing the parent also closes the still-open child.
yield* Scope.close(parent, Exit.void)
})
Effect.runPromise(program)
// => child cleanup
// => parent cleanup

The synchronous variant of fork: creates a child scope without wrapping it in an Effect. Use only in low-level, non-Effect integration code.

import { Scope } from "effect"
const parent = Scope.makeUnsafe("sequential")
const child = Scope.forkUnsafe(parent, "parallel")
console.log(child.strategy) // => "parallel"
console.log(child.state._tag) // => "Empty"

Closes a scope and runs its finalizers with the supplied Exit. This is itself an Effect — finalizers run only when it is executed.

import { Effect, Exit, Scope, Console } from "effect"
const program = Effect.gen(function*() {
const scope = yield* Scope.make("sequential")
yield* Scope.addFinalizer(scope, Console.log("a"))
yield* Scope.addFinalizer(scope, Console.log("b"))
yield* Scope.close(scope, Exit.succeed("ok"))
})
Effect.runPromise(program)
// => b
// => a

Transitions a scope to Closed synchronously and returns an Effect that runs the finalizers (or undefined if there is nothing to run). For low-level machinery only — ignoring the returned effect skips the finalizers.

import { Effect, Exit, Scope, Console } from "effect"
const scope = Scope.makeUnsafe()
Effect.runSync(Scope.addFinalizer(scope, Console.log("cleanup")))
// Returns the finalizer effect; you must run it.
const finalize = Scope.closeUnsafe(scope, Exit.void)
if (finalize) Effect.runSync(finalize)
// => cleanup

Runs an effect with a Closeable scope in its context and closes that scope with the effect’s Exit when it exits. Use it when you already hold a scope and want automatic closing (whereas provide leaves it open).

import { Effect, Scope, Console } from "effect"
const work = Effect.gen(function*() {
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(scope, Console.log("released"))
yield* Console.log("working")
})
const program = Effect.gen(function*() {
const scope = yield* Scope.make()
yield* Scope.use(work, scope) // runs `work`, then closes `scope`
})
Effect.runPromise(program)
// => working
// => released

Now that you understand scopes and finalizers, the next page covers the ergonomic, leak-safe way to define resources: acquire / release. To pool and reuse expensive scoped resources, see Pool.