From 8b7fedaf1899ce6ea62c4499f6d7b6dae6358bd4 Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Mon, 4 May 2026 23:16:59 +0200 Subject: [PATCH 1/4] feat(effect): add `Effect.acquireDisposable` --- packages/effect/src/Effect.ts | 23 ++++++++++++++++++++ packages/effect/src/internal/fiberRuntime.ts | 11 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index d9143c239b6..72b26918b35 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -5460,6 +5460,29 @@ export const acquireRelease: { ): Effect } = fiberRuntime.acquireRelease +/** + * Constructs a scoped resource from an {@linkcode AsyncDisposable} or {@linkcode Disposable} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using} + * + * @example + * import sqlite from "node:sqlite"; + * import { Effect } from "effect"; + * + * // Define how the resource is acquired + * const acquire = Effect.sync(() => new sqlite.DatabaseSync(":memory:")) + * const resource = Effect.acquireDisposable(acquire) + * + * @see {@link acquireRelease} for more information about scopes. + * + * @since 3.0.0 + * @category Scoping, Resources & Finalization + */ +export const acquireDisposable: { + ( + acquire: Effect + ): Effect +} = fiberRuntime.acquireDisposable + /** * Creates a scoped resource with an interruptible acquire action. * diff --git a/packages/effect/src/internal/fiberRuntime.ts b/packages/effect/src/internal/fiberRuntime.ts index f93e75c92f9..47b399dd80e 100644 --- a/packages/effect/src/internal/fiberRuntime.ts +++ b/packages/effect/src/internal/fiberRuntime.ts @@ -1691,6 +1691,17 @@ export const acquireRelease: { core.tap(acquire, (a) => addFinalizer((exit) => release(a, exit))) )) +/* @internal */ +export const acquireDisposable: { + ( + acquire: Effect.Effect + ): Effect.Effect +} = (acquire) => + acquireRelease(acquire, (resource) => + Predicate.hasProperty(resource, Symbol.asyncDispose) + ? internalEffect.promise(() => resource[Symbol.asyncDispose]()) + : core.sync(() => resource[Symbol.dispose]())) + /* @internal */ export const acquireReleaseInterruptible: { ( From 8d28ff301ddd819dfe7e9cd08fe7f616e20c3465 Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Mon, 4 May 2026 23:18:32 +0200 Subject: [PATCH 2/4] test(effect): happy path and failure for `Effect.acquireDisposable` --- .../test/Effect/acquire-release.test.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/effect/test/Effect/acquire-release.test.ts b/packages/effect/test/Effect/acquire-release.test.ts index 575b8923ba9..42c6d25d5d1 100644 --- a/packages/effect/test/Effect/acquire-release.test.ts +++ b/packages/effect/test/Effect/acquire-release.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "@effect/vitest" -import { assertTrue, strictEqual } from "@effect/vitest/utils" +import { assertEquals, assertFalse, assertTrue, strictEqual } from "@effect/vitest/utils" import * as Cause from "effect/Cause" import * as Chunk from "effect/Chunk" import * as Effect from "effect/Effect" @@ -8,6 +8,17 @@ import * as Exit from "effect/Exit" import { pipe } from "effect/Function" import * as Ref from "effect/Ref" +const disposable = (hook: () => void) => ({ + [Symbol.dispose]() { + hook() + } +}) +const asyncDisposable = (hook: () => Promise) => ({ + async [Symbol.asyncDispose]() { + await hook() + } +}) + describe("Effect", () => { it.effect("acquireUseRelease - happy path", () => Effect.gen(function*() { @@ -108,4 +119,59 @@ describe("Effect", () => { assertTrue(equals(Cause.defects(result), Chunk.of(useDied))) assertTrue(released) })) + it.effect("acquireDisposable - happy path", () => + Effect.gen(function*() { + let disposed = false + yield* Effect.succeed( + disposable(() => { + disposed = true + }) + ) + .pipe( + Effect.acquireDisposable, + Effect.tap(() => assertFalse(disposed)), + Effect.scoped + ) + assertTrue(disposed) + })) + it.effect("acquireDisposable - happy path async", () => + Effect.gen(function*() { + let disposed = false + yield* Effect.succeed( + asyncDisposable(() => + new Promise((resolve) => { + disposed = true + resolve() + }) + ) + ) + .pipe( + Effect.acquireDisposable, + Effect.tap(() => assertFalse(disposed)), + Effect.scoped + ) + assertTrue(disposed) + })) + it.effect("acquireDisposable - error handling", () => + Effect.gen(function*() { + const err = new Error("oh no!") + const exit = yield* Effect.succeed( + disposable(() => { + throw err + }) + ) + .pipe( + Effect.acquireDisposable, + Effect.scoped, + Effect.exit + ) + const result = yield* Exit.matchEffect( + exit, + { + onFailure: Effect.succeed, + onSuccess: () => Effect.fail("effect should have failed") + } + ) + assertEquals(Cause.defects(result), Chunk.of(err)) + })) }) From e3bca390a130cca9fb25fd4e6f6242c3498c5628 Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Mon, 4 May 2026 23:20:03 +0200 Subject: [PATCH 3/4] test(effect): change `assertTrue(equals` to `assertEquals` I would've used `strictEquals` but it does not use the `Equals` trait despite claiming it does --- packages/effect/test/Effect/acquire-release.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/effect/test/Effect/acquire-release.test.ts b/packages/effect/test/Effect/acquire-release.test.ts index 42c6d25d5d1..b01ed90d7a7 100644 --- a/packages/effect/test/Effect/acquire-release.test.ts +++ b/packages/effect/test/Effect/acquire-release.test.ts @@ -3,7 +3,6 @@ import { assertEquals, assertFalse, assertTrue, strictEqual } from "@effect/vite import * as Cause from "effect/Cause" import * as Chunk from "effect/Chunk" import * as Effect from "effect/Effect" -import { equals } from "effect/Equal" import * as Exit from "effect/Exit" import { pipe } from "effect/Function" import * as Ref from "effect/Ref" @@ -64,8 +63,8 @@ describe("Effect", () => { exit, Exit.matchEffect({ onFailure: Effect.succeed, onSuccess: () => Effect.fail("effect should have failed") }) ) - assertTrue(equals(Cause.failures(result), Chunk.of("use failed"))) - assertTrue(equals(Cause.defects(result), Chunk.of(releaseDied))) + assertEquals(Cause.failures(result), Chunk.of("use failed")) + assertEquals(Cause.defects(result), Chunk.of(releaseDied)) })) it.effect("acquireUseRelease - error handling + disconnect", () => Effect.gen(function*() { @@ -86,8 +85,8 @@ describe("Effect", () => { onSuccess: () => Effect.fail("effect should have failed") }) ) - assertTrue(equals(Cause.failures(result), Chunk.of("use failed"))) - assertTrue(equals(Cause.defects(result), Chunk.of(releaseDied))) + assertEquals(Cause.failures(result), Chunk.of("use failed")) + assertEquals(Cause.defects(result), Chunk.of(releaseDied)) })) it.effect("acquireUseRelease - beast mode error handling + disconnect", () => Effect.gen(function*() { @@ -116,7 +115,7 @@ describe("Effect", () => { ) ) const released = yield* (Ref.get(release)) - assertTrue(equals(Cause.defects(result), Chunk.of(useDied))) + assertEquals(Cause.defects(result), Chunk.of(useDied)) assertTrue(released) })) it.effect("acquireDisposable - happy path", () => From 27ba280c54658bdaa72980fec90edbb7e5015cbb Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Mon, 4 May 2026 23:20:20 +0200 Subject: [PATCH 4/4] chore: add changeset --- .changeset/big-steaks-train.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/big-steaks-train.md diff --git a/.changeset/big-steaks-train.md b/.changeset/big-steaks-train.md new file mode 100644 index 00000000000..dd3d3c2a0b9 --- /dev/null +++ b/.changeset/big-steaks-train.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add `Effect.acquireDisposable`