Skip to content

Acquire / release

The acquireRelease family is the recommended way to manage a resource. It pairs an acquire effect with a release effect and guarantees the release runs when the scope closes. Crucially, acquisition runs in an uninterruptible region: once you have started acquiring, you are guaranteed to either fully acquire the resource (and register its release) or not acquire it at all. There is no window where a resource is half-open with no finalizer attached — the classic source of leaks.

Effect.acquireRelease — scoped resources

Section titled “Effect.acquireRelease — scoped resources”

Effect.acquireRelease(acquire, release) produces an Effect that yields the resource and adds release to the current scope. The release receives the Exit the scope closed with, so it can behave differently on success vs. failure.

import { Effect, Exit, Console } from "effect"
interface FileHandle {
readonly path: string
}
// `acquire` opens the resource; `release` closes it and can inspect the Exit.
const openFile = (path: string) =>
Effect.acquireRelease(
Console.log(`opening ${path}`).pipe(Effect.as<FileHandle>({ path })),
(handle, exit) =>
Console.log(
`closing ${handle.path} (${Exit.isSuccess(exit) ? "ok" : "errored"})`
)
)
const program = Effect.gen(function*() {
// Yielding the resource registers its release in the surrounding scope.
const file = yield* openFile("/tmp/data.txt")
yield* Console.log(`reading ${file.path}`)
return "contents"
})
// `Effect.scoped` defines the lifetime: the file is closed when the block ends.
Effect.runPromise(Effect.scoped(program)).then(console.log)
/*
Output:
opening /tmp/data.txt
reading /tmp/data.txt
closing /tmp/data.txt (ok)
contents
*/

The resource type carries Scope in its requirements (Effect<FileHandle, never, Scope>) until you wrap it with Effect.scoped (or provide it to a Layer, see below). That requirement is the type system enforcing that the resource must live inside some scope.

Effect.acquireUseRelease — the bracket pattern

Section titled “Effect.acquireUseRelease — the bracket pattern”

When a resource is acquired, used, and released all in one place, reach for Effect.acquireUseRelease(acquire, use, release). It is the classic bracket: the resource is acquired, the use function runs with it, and release runs afterward — even if use fails or is interrupted. Unlike acquireRelease, it does not require a Scope; the lifetime is exactly the use call.

import { Effect, Console } from "effect"
interface Connection {
readonly id: number
readonly query: (sql: string) => Effect.Effect<ReadonlyArray<string>>
}
let nextId = 0
const acquire = Effect.sync<Connection>(() => {
const id = ++nextId
return {
id,
query: (sql) => Console.log(`[conn ${id}] ${sql}`).pipe(Effect.as(["row"]))
}
}).pipe(Effect.tap((c) => Console.log(`acquired connection ${c.id}`)))
const program = Effect.acquireUseRelease(
// 1. acquire the resource
acquire,
// 2. use it — this is the only place the resource is in scope
(conn) => conn.query("SELECT 1"),
// 3. release it, with access to the resource and the Exit of `use`
(conn) => Console.log(`releasing connection ${conn.id}`)
)
Effect.runPromise(program).then(console.log)
/*
Output:
acquired connection 1
[conn 1] SELECT 1
releasing connection 1
[ 'row' ]
*/

Which one do I use?

  • Use acquireUseRelease when acquire → use → release is a single self-contained operation. It needs no scope and reads top to bottom.
  • Use acquireRelease when the resource must outlive a single expression — when several steps share it, or when it backs a long-lived service. You decide its lifetime by choosing where to call Effect.scoped, or by attaching it to a Layer.

The most common production pattern is to acquire a resource while constructing a service. A Layer built with Layer.effect runs inside its own scope, so a resource acquired with acquireRelease during construction is released exactly when the layer is torn down — typically at application shutdown. You get correct startup/shutdown ordering for free.

import { Config, Context, Effect, Layer, Redacted, Schema } from "effect"
import * as NodeMailer from "nodemailer"
// Use `Schema.Defect` to carry an unknown thrown cause on the error.
class SmtpError extends Schema.TaggedErrorClass<SmtpError>()("SmtpError", {
cause: Schema.Defect
}) {}
// A v4 service: `Context.Service` with a static `layer`.
class Smtp extends Context.Service<Smtp, {
send(message: {
readonly to: string
readonly subject: string
readonly body: string
}): Effect.Effect<void, SmtpError>
}>()("app/Smtp") {
static readonly layer = Layer.effect(
Smtp,
Effect.gen(function*() {
const user = yield* Config.string("SMTP_USER")
const pass = yield* Config.redacted("SMTP_PASS")
// The transporter is acquired while the layer is built and closed when
// the layer's scope closes — i.e. when the app shuts down. No manual
// wiring of `Scope` is needed; `Layer.effect` provides it.
const transporter = yield* Effect.acquireRelease(
Effect.sync(() =>
NodeMailer.createTransport({
host: "smtp.example.com",
port: 587,
secure: false,
auth: { user, pass: Redacted.value(pass) }
})
),
(transporter) => Effect.sync(() => transporter.close())
)
const send = Effect.fn("Smtp.send")((message: {
readonly to: string
readonly subject: string
readonly body: string
}) =>
Effect.tryPromise({
try: () =>
transporter.sendMail({
from: "Acme Cloud <cloud@acme.com>",
to: message.to,
subject: message.subject,
text: message.body
}),
catch: (cause) => new SmtpError({ cause })
}).pipe(Effect.asVoid)
)
return Smtp.of({ send })
})
)
}

Because the cleanup is baked into the layer, every consumer of Smtp benefits automatically — they never see the transporter, the scope, or the finalizer. This is the idiomatic place for acquireRelease: at the seam between the outside world and your service graph.

For resources that run continuously in the background rather than answering calls, see background tasks.