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 orderfinalizer 1 — exit was Success*/Two things to internalize from this example:
programhas typeEffect<string, never, Scope>. TheScopein the requirements channel means “I register finalizers and need a scope to run them in.”Effect.scopedprovides that scope and removes the requirement.- Finalizers run in reverse order of registration, like unwinding a stack.
Finalizers run on every outcome
Section titled “Finalizers run on every outcome”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' ... } }*/ensuring, onExit, and onError
Section titled “ensuring, onExit, and onError”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.ensuringruns a finalizer whether the effect succeeds, fails, or is interrupted. It does not see the result. This is the closest analogue to afinallyblock.Effect.onExitruns cleanup and receives the fullExit, so it can branch on success vs. failure vs. interruption. The cleanup is uninterruptible.Effect.onErrorruns cleanup only when the effect fails (including by interruption), receiving theCause.
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 42ensuring: always runs*/Nested lifetimes
Section titled “Nested lifetimes”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 outeracquire innerusing innerrelease inner <-- inner scope closes hereafter innerrelease outer <-- outer scope closes when program ends*/Working with a scope directly
Section titled “Working with a scope directly”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:workrelease Brelease A*/Now that you understand scopes and finalizers, the next page covers the ergonomic, leak-safe way to define resources: acquire / release.