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`
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: {
(
diff --git a/packages/effect/test/Effect/acquire-release.test.ts b/packages/effect/test/Effect/acquire-release.test.ts
index 575b8923ba9..b01ed90d7a7 100644
--- a/packages/effect/test/Effect/acquire-release.test.ts
+++ b/packages/effect/test/Effect/acquire-release.test.ts
@@ -1,13 +1,23 @@
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"
-import { equals } from "effect/Equal"
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*() {
@@ -53,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*() {
@@ -75,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*() {
@@ -105,7 +115,62 @@ 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", () =>
+ 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))
+ }))
})