07·01Scope

A `Scope` is a lifetime. Finalizers attached to a scope run when the scope closes, on success, on failure, or on interruption.

46 - Scope: Resource Lifecycles That Cannot Leak

Previous · Next

A Scope is a lifetime. Finalizers attached to a scope run when the scope closes, on success, on failure, or on interruption.

The two operators that matter day-to-day are Effect.acquireRelease (pair a resource with its cleanup) and Effect.scoped (open and close a scope at a precise point). Get those two right and resources cannot leak.

What you’ll work through

index.test.ts contains four beats: pairing a resource with its cleanup via Effect.acquireRelease, attaching a free-standing finalizer with Effect.addFinalizer, opening and closing a scope explicitly with Effect.scoped, and asserting that finalizers run in LIFO order.

By the end you should treat Scope as the resource model underneath Layers, queues, files, sockets, and any other thing that owns external state.


Concept

Effect.scoped ──▶ open scope ┐
Effect.acquireRelease(get, close) ──▶ run get │
... │
Effect.acquireRelease(open, end) ──▶ run open │
... │
◀── close scope (run finalizers in LIFO order)
If acquire fails ──▶ no release runs (nothing was acquired).
If body fails ──▶ every release still runs, then failure propagates.
If fiber is interrupted ──▶ every release still runs, then interruption propagates.

Finalizers run in LIFO order, the reverse of registration, closer cleans up before farther.


Effect.acquireRelease

Creates a scoped resource: pair a value-producing Effect (acquire) with a cleanup function (release). The resulting Effect has Scope in its requirements until you provide one.

import { Effect } from "effect"
interface Connection {
readonly id: number
readonly close: () => void
}
const open: Effect.Effect<Connection> = Effect.sync(() => ({
id: 1,
close: () => {},
}))
// ┌─── Effect<Connection, never, Scope>
// ▼
const managed = Effect.acquireRelease(
open,
(conn) => Effect.sync(() => conn.close()),
)

Details

The release callback receives the resource and the Exit describing how the scope is closing. Inspect the Exit if cleanup differs between success and failure.

When to Use

For anything with a paired open/close: connections, files, locks, subscriptions, child processes.


Effect.addFinalizer

Attaches a finalizer to the current scope without producing a resource. Useful when cleanup is logical rather than tied to a value.

// ┌─── Effect<void, never, Scope>
// ▼
const logShutdown = Effect.addFinalizer((exit) =>
Effect.sync(() => console.log(`closing, exit: ${exit._tag}`)),
)

Effect.scoped

Provides a fresh Scope, runs the inner Effect, and closes the scope when the Effect ends. This is the boundary that removes Scope from R.

// ┌─── Effect<Connection, never, never>
// ▼
const program = Effect.scoped(managed)

When to Use

At the boundary where the resource’s lifetime should end. For services owned by the application, that boundary is the layer (Layer.effect), the scope closes on shutdown.

For short-lived resources, scope the smallest block that needs them.


Composite Example: Acquire / Use / Cleanup

// ┌─── Effect<number, never, never>
// ▼
const program = Effect.scoped(
Effect.gen(function* () {
const conn = yield* Effect.acquireRelease(
Effect.sync(() => ({ id: 42, close: () => {} })),
(c) => Effect.sync(() => c.close()),
)
yield* Effect.addFinalizer(() => Effect.sync(() => {/* flush metrics */}))
return conn.id
}),
)

When the inner block ends, finalizers run in reverse order: the metric flush first, then the connection close.



Use-after-scope is a bug

Effect.scoped closes the scope when its body finishes — every resource acquired inside is finalized at that boundary. Capturing a reference to the resource and using it outside Effect.scoped is a use-after-close bug.

With trivial in-memory values the bug is silent; with real resources (file handles, DB connections, sockets) the call throws.

import { Effect } from "effect"
// ✗ Bug: connection escapes the scope that owns its lifetime.
const broken = Effect.gen(function* () {
let captured: Connection | undefined
yield* Effect.scoped(
Effect.gen(function* () {
const conn = yield* openConnection
captured = conn // hold onto it past the scope
return conn.query("SELECT 1")
}),
)
// captured is closed here — using it throws.
return captured!.query("SELECT 2")
})

The fix is to keep the resource use inside Effect.scoped, or to hoist the scope outward so it spans the entire lifetime the resource is used.

Your turn read the test that captures a resource and tries to use it after the scope ends — confirm the failure exit. Then complete the correct version where the use stays inside `Effect.scoped`.


Common Pitfalls

1, Forgetting Effect.scoped. An effect with Scope in its requirements is unrunnable until a scope is provided. The type system catches this; trust the error.

2, Closing the scope too early. Wrapping a service constructor in Effect.scoped closes the scope before the service is returned, so the resource is released immediately. Hoist Effect.scoped to the consumer, or use Layer.effect so the scope is the layer’s lifetime.

3, Throwing inside release. A throw in the release function is a defect, it cannot be recovered from with catch. Keep release functions infallible; if cleanup can fail, use Effect.catch inside the release to swallow or log.

4, Confusing Scope with try/finally. try/finally only fires on synchronous return. Scopes fire on success, failure, and interruption, the third case is what makes resources leak under cancellation.


confirm it runs on success, failure, and interruption. - [ ] Open a scope explicitly with `Effect.scoped` to control when finalizers fire. - [ ] Confirm finalizers run in LIFO order — the test asserts the order.

See Also

  • Previous, 40-synchronized-ref: serialized effectful updates.
  • Next, 47-cache: a Scope-backed primitive you’ll use often.
  • Related, 22-layers: Layer.effect is where most production scopes live.
  • Related, 36-fibers: interruption is the case scopes were built for.

Run the tests: pnpm test 46-scope


The full test suite

The index.test.ts for this lesson, verbatim from steps/46-scope/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Effect, Exit, Ref } from "effect"
// =============================================================================
// 37 - Scope: Resource Lifecycles That Cannot Leak
// =============================================================================
//
// Effect.acquireRelease pair a resource with its cleanup — Effect<A, E, Scope>
// Effect.addFinalizer attach a finalizer to the scope — Effect<void, never, Scope>
// Effect.scoped provide a fresh Scope, then close it — removes Scope from R
describe("37 - Scope", () => {
// @beat
// id: acquire-release
// shape: one-todo
// title: Pair acquire with release
// teaches: Effect.acquireRelease pairs a resource-producing Effect with a cleanup function; the resulting Effect requires a Scope.
// hint: Effect.acquireRelease(acquireEffect, (resource) => releaseEffect)
describe("Effect.acquireRelease", () => {
it("releases the resource when the scope closes", async () => {
const events: Array<string> = []
// ┌─── Effect<{ readonly id: number }, never, Scope>
// ▼
const managed = /* @stub */ Effect.acquireRelease(
Effect.sync(() => {
events.push("acquired")
return { id: 42 }
}),
(resource) =>
Effect.sync(() => {
events.push(`released ${resource.id}`)
}),
) /* @end */
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const resource = yield* managed
return resource.id
}),
),
)
expect(result).toBe(42)
expect(events).toEqual(["acquired", "released 42"])
})
it("releases even when the body fails", async () => {
const released: Array<boolean> = []
const exit = await Effect.runPromiseExit(
Effect.scoped(
Effect.gen(function* () {
yield* Effect.acquireRelease(
Effect.succeed("connection"),
() => Effect.sync(() => released.push(true)),
)
return yield* Effect.fail("db error")
}),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(released).toEqual([true])
})
})
// @end-beat
// @beat
// id: scoped-boundary
// shape: one-todo
// title: Close the scope with Effect.scoped
// teaches: Effect.scoped opens a fresh Scope, runs the body, and closes the scope — removing Scope from R.
// hint: wrap the inner Effect.gen with Effect.scoped(...)
describe("Effect.scoped", () => {
it("removes Scope from the requirements", async () => {
// ┌─── Effect<number, never, never>
// ▼
const program = /* @stub */ Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.void)
return 1
}),
) /* @end */
expect(await Effect.runPromise(program)).toBe(1)
})
})
// @end-beat
// @beat
// id: add-finalizer
// shape: read-run
// title: Attach cleanup with addFinalizer
// teaches: Effect.addFinalizer attaches cleanup to the current scope; the finalizer runs on success and on failure.
// hint: read both tests — the finalizer fires whether the body succeeds or fails
describe("Effect.addFinalizer", () => {
it("runs on success", async () => {
const program = Effect.gen(function* () {
const log = yield* Ref.make<ReadonlyArray<string>>([])
const body = Effect.gen(function* () {
yield* Effect.addFinalizer(() => Ref.update(log, (arr) => [...arr, "cleanup"]))
return "result"
})
const result = yield* Effect.scoped(body)
const events = yield* Ref.get(log)
return { result, events }
})
const out = await Effect.runPromise(program)
expect(out.result).toBe("result")
expect(out.events).toEqual(["cleanup"])
})
it("runs on failure", async () => {
const log: Array<string> = []
const exit = await Effect.runPromiseExit(
Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => log.push("cleanup")))
return yield* Effect.fail("oops")
}),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(log).toEqual(["cleanup"])
})
})
// @end-beat
// @beat
// id: lifo-finalizers
// shape: one-todo
// title: Finalizers run in LIFO order
// teaches: Finalizers fire in reverse registration order — closer cleans up before farther.
// hint: register "first" then "second"; the close order is ["second", "first"]
it("runs finalizers in LIFO order", async () => {
const log: Array<string> = []
await Effect.runPromise(
Effect.scoped(
/* @stub */ Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => log.push("first")))
yield* Effect.addFinalizer(() => Effect.sync(() => log.push("second")))
}) /* @end */,
),
)
expect(log).toEqual(["second", "first"])
})
// @end-beat
// @beat
// id: use-after-scope
// shape: multi-todo
// title: Use a resource inside its scope — not outside
// teaches: Effect.scoped closes the scope when the body finishes; any resource obtained inside is finalized. Capturing a reference and using it outside is a bug — in real resources (file handles, DB connections) it throws.
// hint: move the resource use inside the Effect.scoped block — the scope guarantees the resource is live for the duration
describe("Effect.scoped boundary", () => {
it("a resource finalized outside its scope cannot be used safely (read this)", async () => {
const log: Array<string> = []
let capturedQuery: (() => string) | null = null
const dbResource = Effect.acquireRelease(
Effect.sync(() => ({
query: () => {
if (log.includes("released")) throw new Error("connection closed")
return "result"
},
})),
() => Effect.sync(() => log.push("released")),
)
// Scope closes at the end of Effect.scoped — capturedQuery is now "dead"
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const db = yield* dbResource
capturedQuery = db.query // captured but will be finalized
}),
),
)
expect(log).toContain("released")
// Using the captured reference outside the scope throws
expect(() => capturedQuery!()).toThrow("connection closed")
})
it("use the resource inside Effect.scoped — guaranteed live", async () => {
const log: Array<string> = []
const dbResource = Effect.acquireRelease(
Effect.sync(() => ({ query: () => "result" })),
() => Effect.sync(() => log.push("released")),
)
const result = await Effect.runPromise(
/* @stub */ Effect.scoped(
Effect.gen(function* () {
const db = yield* dbResource
return db.query() // use inside — resource is live here
}),
) /* @end */,
)
expect(result).toBe("result")
expect(log).toContain("released")
})
})
// @end-beat
})
← PreviousPubSubNext →Cache