diff --git a/.changeset/brave-owls-bracket.md b/.changeset/brave-owls-bracket.md
new file mode 100644
index 0000000..5e6e4ca
--- /dev/null
+++ b/.changeset/brave-owls-bracket.md
@@ -0,0 +1,5 @@
+---
+"fluent-effect": minor
+---
+
+Add `fx.acquireUseRelease` and `fx.bracket` for resource-safe acquire/use/release workflows.
diff --git a/docs/README.md b/docs/README.md
index bcded9f..372a76a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -21,6 +21,8 @@ that differ from raw Effect defaults.
provider helpers, layer composition, and runtime application wiring.
- [Concurrency](./concurrency.md) covers sequential defaults, unbounded
concurrency, bounded concurrency, and discard traversal helpers.
+- [Resource Safety](./resources.md) covers acquire/use/release workflows and
+ when to use the native Effect escape hatch.
- [Retry and Timeout](./retry-timeout.md) covers retry attempt counting,
backoff options, native schedules, and timeout failure behavior.
- [Package Exports](./package-exports.md) covers the package entrypoints and
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 54104df..516d9e2 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -16,6 +16,8 @@ import { fx } from "fluent-effect";
| `fx.fail` | Create a failed task. |
| `fx.try` | Convert throwing or rejecting code into a typed task. |
| `fx.trySync` | Convert synchronous throwing code into a synchronously runnable task. |
+| `fx.acquireUseRelease` | Acquire, use, and release a resource safely. |
+| `fx.bracket` | Alias for `fx.acquireUseRelease`. |
## Errors
diff --git a/docs/resources.md b/docs/resources.md
new file mode 100644
index 0000000..94f9a93
--- /dev/null
+++ b/docs/resources.md
@@ -0,0 +1,46 @@
+# Resource Safety
+
+Use `fx.acquireUseRelease` when a workflow needs a resource to be released
+after it is used, even when use fails or the task is interrupted.
+
+```ts
+import { fx } from "fluent-effect";
+
+const program = fx.acquireUseRelease(
+ fx.try({
+ try: () => openConnection(),
+ catch: (cause) => ({ _tag: "OpenFailed" as const, cause }),
+ }),
+ (connection) =>
+ fx.try({
+ try: () => connection.query("select 1"),
+ catch: (cause) => ({ _tag: "QueryFailed" as const, cause }),
+ }),
+ (connection, exit) =>
+ fx.sync(() => {
+ try {
+ connection.close({ failed: exit._tag === "Failure" });
+ } catch (cause) {
+ console.error("connection cleanup failed", cause);
+ }
+ }),
+);
+```
+
+`fx.bracket` is the same helper under a shorter traditional name.
+
+## Typing
+
+The returned task includes the acquire and use success/failure/dependency
+types, plus any dependencies needed by the release finalizer. The release
+success value is discarded.
+
+Effect finalizers are expected not to introduce typed failures. If cleanup can
+fail, handle that failure inside the release task by logging, reporting, or
+defecting intentionally with native Effect APIs.
+
+## Native Escape Hatch
+
+Prefer `fx.acquireUseRelease` for direct acquire/use/release workflows. Import
+from `fluent-effect/effect` when you need lower-level scoped resources, layers,
+custom finalizer composition, or other native resource primitives.
diff --git a/src/builders.ts b/src/builders.ts
index 550d7a8..13f2bc8 100644
--- a/src/builders.ts
+++ b/src/builders.ts
@@ -99,3 +99,9 @@ export const _try = (options: TryOptions): Task =>
/** Wrap a synchronous function that might throw into a Task. */
export const trySync = (options: { try: () => A; catch: (e: unknown) => E }): Task =>
Effect.try(options);
+
+/** Acquire a resource, use it, and release it on success, failure, or interruption. */
+export const acquireUseRelease = Effect.acquireUseRelease;
+
+/** Alias for acquire-use-release resource safety. */
+export const bracket = acquireUseRelease;
diff --git a/src/index.ts b/src/index.ts
index dd4ffa9..1ecfd28 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -49,6 +49,8 @@ export const fx = {
errors: builders.errors,
try: builders._try,
trySync: builders.trySync,
+ acquireUseRelease: builders.acquireUseRelease,
+ bracket: builders.bracket,
// Combinators
map: concurrency.map,
diff --git a/test/runtime.test.ts b/test/runtime.test.ts
index 58d036e..414a419 100644
--- a/test/runtime.test.ts
+++ b/test/runtime.test.ts
@@ -592,6 +592,91 @@ describe("fx runtime behavior", () => {
expect(signalWasAborted).toBe(true);
});
+ test("fx.acquireUseRelease releases resources after success", async () => {
+ const events: string[] = [];
+
+ const result = await fx.run(
+ fx.acquireUseRelease(
+ fx.sync(() => {
+ events.push("acquire");
+ return "resource";
+ }),
+ (resource) =>
+ fx.sync(() => {
+ events.push(`use:${resource}`);
+ return "done";
+ }),
+ (resource, exit) =>
+ fx.sync(() => {
+ events.push(`release:${resource}:${exit._tag}`);
+ }),
+ ),
+ );
+
+ expect(result).toBe("done");
+ expect(events).toEqual(["acquire", "use:resource", "release:resource:Success"]);
+ });
+
+ test("fx.acquireUseRelease releases resources after failure", async () => {
+ const AppError = fx.errors<{ Boom: { message: string } }>();
+ const events: string[] = [];
+
+ const result = await fx.runExit(
+ fx.acquireUseRelease(
+ fx.sync(() => {
+ events.push("acquire");
+ return "resource";
+ }),
+ (resource) =>
+ fx.task(function* () {
+ events.push(`use:${resource}`);
+ return yield* fx.fail(AppError.Boom({ message: "bad" }));
+ }),
+ (resource, exit) =>
+ fx.sync(() => {
+ events.push(`release:${resource}:${exit._tag}`);
+ }),
+ ),
+ );
+
+ expect(result._tag).toBe("Failure");
+ expect(events).toEqual(["acquire", "use:resource", "release:resource:Failure"]);
+ });
+
+ test("fx.bracket releases resources after interruption", async () => {
+ const result = await fx.run(
+ Effect.gen(function* () {
+ const released = yield* Deferred.make();
+
+ const fiber = yield* Effect.fork(
+ fx.bracket(
+ fx.succeed("resource"),
+ () => Effect.never,
+ (resource, exit) =>
+ fx
+ .sync(() => {
+ expect(resource).toBe("resource");
+ expect(exit._tag).toBe("Failure");
+ })
+ .pipe(Effect.zipRight(Deferred.succeed(released, undefined))),
+ ),
+ );
+
+ yield* Effect.timeoutFail(
+ Fiber.interrupt(fiber).pipe(Effect.zipRight(Deferred.await(released))),
+ {
+ duration: "1 second",
+ onTimeout: () => new Error("Timed out waiting for interrupted bracket to release"),
+ },
+ );
+
+ return "released";
+ }),
+ );
+
+ expect(result).toBe("released");
+ });
+
test("fx.onSuccess and fx.onFailure run hooks without changing the original result", async () => {
const AppError = fx.errors<{ Boom: { message: string } }>();
const events: string[] = [];
diff --git a/test/types.test.ts b/test/types.test.ts
index 01adcfe..855a005 100644
--- a/test/types.test.ts
+++ b/test/types.test.ts
@@ -85,6 +85,39 @@ const syncWrapped = fx.trySync({
type _try_sync_result = Expect, User>>;
type _try_sync_error = Expect, NetworkError>>;
+interface ReleaseAudit {
+ readonly record: (message: string) => Task;
+}
+
+const ReleaseAudit = fx.dependency("ReleaseAudit");
+
+const resourceManaged = fx.acquireUseRelease(
+ fx.trySync({
+ try: () => ({ id: "resource" }),
+ catch: (cause) => new NetworkError(cause),
+ }),
+ (resource) => (resource.id === user.id ? fx.ok(user) : fx.fail(new NotFound(resource.id))),
+ (resource, exit) =>
+ fx.task(function* () {
+ const audit = yield* fx.getDependency(ReleaseAudit);
+ yield* audit.record(`${resource.id}:${exit._tag}`);
+ }),
+);
+
+const bracketManaged = fx.bracket(
+ fx.ok({ id: "resource" }),
+ (resource) => fx.ok(resource.id),
+ () => fx.ok(undefined),
+);
+
+type _acquire_use_release_result = Expect, User>>;
+type _acquire_use_release_error = Expect<
+ Equal, NetworkError | NotFound>
+>;
+type _acquire_use_release_deps = Expect, ReleaseAudit>>;
+type _bracket_result = Expect, string>>;
+type _bracket_error = Expect, never>>;
+
const succeeded = fx.succeed(user);
const fromSync = fx.fromSync(() => user);