From 9c4180642280a05d44dbd5b225c575ad6b87e1c3 Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Sun, 15 Feb 2026 19:10:40 +0100 Subject: [PATCH 1/7] Add using() API with runtime validation and tests --- lib/mod.ts | 1 + lib/using.ts | 60 +++++++++++++++++++++ test/using.test.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 lib/using.ts create mode 100644 test/using.test.ts diff --git a/lib/mod.ts b/lib/mod.ts index e15e4d3c5..64d8bd609 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -25,3 +25,4 @@ export * from "./with-resolvers.ts"; export * from "./async.ts"; export * from "./scoped.ts"; export * from "./until.ts"; +export * from "./using.ts"; diff --git a/lib/using.ts b/lib/using.ts new file mode 100644 index 000000000..58715112f --- /dev/null +++ b/lib/using.ts @@ -0,0 +1,60 @@ +import { call } from "./call.ts"; +import { resource } from "./resource.ts"; + +type DisposableLike = { + [Symbol.dispose]?(): void; + [Symbol.asyncDispose]?(): PromiseLike | void; +}; + +function getDisposer(value: DisposableLike): () => PromiseLike | void { + let asyncDispose = value[Symbol.asyncDispose]; + + if (typeof asyncDispose === "function") { + return asyncDispose.bind(value); + } + + let dispose = value[Symbol.dispose]; + + if (typeof dispose === "function") { + return dispose.bind(value); + } + + throw new TypeError( + "using() value must implement Symbol.dispose or Symbol.asyncDispose", + ); +} + +/** + * Bind a JavaScript disposable value to the current Effection scope. + * + * The provided value is yielded immediately, then disposed when the owning + * scope exits (on return, error, or halt). + * + * @example + * ```ts + * import { run, using } from "effection"; + * + * class Connection { + * opened = true; + * [Symbol.dispose]() { + * this.opened = false; + * } + * } + * + * await run(function* () { + * let connection = yield* using(new Connection()); + * connection.opened; // true while in scope + * }); + * ``` + */ +export function* using(value: T) { + let disposer = getDisposer(value); + + return yield* resource(function* (provide) { + try { + yield* provide(value); + } finally { + yield* call(() => disposer()); + } + }); +} diff --git a/test/using.test.ts b/test/using.test.ts new file mode 100644 index 000000000..ccd6944cb --- /dev/null +++ b/test/using.test.ts @@ -0,0 +1,128 @@ +import { createScope, run, suspend, using } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +class Resource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + [Symbol.dispose]() { + this.isDisposed = true; + } +} + +class AsyncResource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + async [Symbol.asyncDispose]() { + await Promise.resolve(void 0); + this.isDisposed = true; + } +} + +class DelayedAsyncResource { + isDisposed = false; + disposeStarted = false; + + async [Symbol.asyncDispose]() { + this.disposeStarted = true; + await new Promise((resolve) => setTimeout(resolve, 20)); + this.isDisposed = true; + } +} + +describe("using", () => { + it("should dispose sync disposable value without the native 'using' keyword", async () => { + let value: number | undefined; + let resource = new Resource(); + + await run(function* () { + let ref = yield* using(resource); + value = ref.getValue(); + }); + + expect(value).toBeDefined(); + expect(value).toBe(100); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("should dispose async disposable value without the native 'using' keyword", async () => { + let value: number | undefined; + let resource = new AsyncResource(); + + await run(function* () { + let ref = yield* using(resource); + value = ref.getValue(); + }); + + expect(value).toBeDefined(); + expect(value).toBe(100); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("disposes resources when the operation errors", async () => { + let resource = new Resource(); + let error = new Error("boom"); + + await expect( + run(function* () { + yield* using(resource); + throw error; + }), + ).rejects.toBe(error); + + expect(resource.isDisposed).toBeTruthy(); + }); + + it("disposes resources when the owning scope is halted", async () => { + let [scope, destroy] = createScope(); + let resource = new Resource(); + let resolver: (() => void) | undefined; + let started = new Promise((resolve) => (resolver = resolve)); + + let task = scope.run(function* () { + yield* using(resource); + resolver?.(); + yield* suspend(); + }); + + await started; + await expect(destroy()).resolves.toBeUndefined(); + await expect(task).rejects.toThrow("halted"); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("waits for async disposal before completing", async () => { + let resource = new DelayedAsyncResource(); + + await run(function* () { + yield* using(resource); + }); + + expect(resource.disposeStarted).toBeTruthy(); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("errors on non-disposable runtime values", async () => { + await expect( + run(function* () { + // deno-lint-ignore no-explicit-any + yield* using({} as any); + }), + ).rejects.toThrow(); + }); +}); From 592f6c1a7cf5bc5360c003ed042756b87a6e5f71 Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Sun, 15 Feb 2026 19:10:40 +0100 Subject: [PATCH 2/7] Add using() API with runtime validation and tests --- lib/mod.ts | 1 + lib/using.ts | 63 ++++++++++++++++++++++ test/using.test.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 lib/using.ts create mode 100644 test/using.test.ts diff --git a/lib/mod.ts b/lib/mod.ts index e15e4d3c5..64d8bd609 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -25,3 +25,4 @@ export * from "./with-resolvers.ts"; export * from "./async.ts"; export * from "./scoped.ts"; export * from "./until.ts"; +export * from "./using.ts"; diff --git a/lib/using.ts b/lib/using.ts new file mode 100644 index 000000000..b19ed369f --- /dev/null +++ b/lib/using.ts @@ -0,0 +1,63 @@ +import { call } from "./call.ts"; +import { resource } from "./resource.ts"; +import type { Operation } from "./types.ts"; + +type DisposableLike = { + [Symbol.dispose]?(): void; + [Symbol.asyncDispose]?(): PromiseLike | void; +}; + +function getDisposer(value: DisposableLike): () => PromiseLike | void { + let asyncDispose = value[Symbol.asyncDispose]; + + if (typeof asyncDispose === "function") { + return asyncDispose.bind(value); + } + + let dispose = value[Symbol.dispose]; + + if (typeof dispose === "function") { + return dispose.bind(value); + } + + throw new TypeError( + "using() value must implement Symbol.dispose or Symbol.asyncDispose", + ); +} + +/** + * Bind a JavaScript disposable value to the current Effection scope. + * + * The provided value is yielded immediately, then disposed when the owning + * scope exits (on return, error, or halt). + * + * @example + * ```ts + * import { run, using } from "effection"; + * + * class Connection { + * opened = true; + * [Symbol.dispose]() { + * this.opened = false; + * } + * } + * + * await run(function* () { + * let connection = yield* using(new Connection()); + * connection.opened; // true while in scope + * }); + * ``` + */ +export function* using( + value: T, +): Operation { + let disposer = getDisposer(value); + + return yield* resource(function* (provide) { + try { + yield* provide(value); + } finally { + yield* call(() => disposer()); + } + }); +} diff --git a/test/using.test.ts b/test/using.test.ts new file mode 100644 index 000000000..ccd6944cb --- /dev/null +++ b/test/using.test.ts @@ -0,0 +1,128 @@ +import { createScope, run, suspend, using } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +class Resource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + [Symbol.dispose]() { + this.isDisposed = true; + } +} + +class AsyncResource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + async [Symbol.asyncDispose]() { + await Promise.resolve(void 0); + this.isDisposed = true; + } +} + +class DelayedAsyncResource { + isDisposed = false; + disposeStarted = false; + + async [Symbol.asyncDispose]() { + this.disposeStarted = true; + await new Promise((resolve) => setTimeout(resolve, 20)); + this.isDisposed = true; + } +} + +describe("using", () => { + it("should dispose sync disposable value without the native 'using' keyword", async () => { + let value: number | undefined; + let resource = new Resource(); + + await run(function* () { + let ref = yield* using(resource); + value = ref.getValue(); + }); + + expect(value).toBeDefined(); + expect(value).toBe(100); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("should dispose async disposable value without the native 'using' keyword", async () => { + let value: number | undefined; + let resource = new AsyncResource(); + + await run(function* () { + let ref = yield* using(resource); + value = ref.getValue(); + }); + + expect(value).toBeDefined(); + expect(value).toBe(100); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("disposes resources when the operation errors", async () => { + let resource = new Resource(); + let error = new Error("boom"); + + await expect( + run(function* () { + yield* using(resource); + throw error; + }), + ).rejects.toBe(error); + + expect(resource.isDisposed).toBeTruthy(); + }); + + it("disposes resources when the owning scope is halted", async () => { + let [scope, destroy] = createScope(); + let resource = new Resource(); + let resolver: (() => void) | undefined; + let started = new Promise((resolve) => (resolver = resolve)); + + let task = scope.run(function* () { + yield* using(resource); + resolver?.(); + yield* suspend(); + }); + + await started; + await expect(destroy()).resolves.toBeUndefined(); + await expect(task).rejects.toThrow("halted"); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("waits for async disposal before completing", async () => { + let resource = new DelayedAsyncResource(); + + await run(function* () { + yield* using(resource); + }); + + expect(resource.disposeStarted).toBeTruthy(); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("errors on non-disposable runtime values", async () => { + await expect( + run(function* () { + // deno-lint-ignore no-explicit-any + yield* using({} as any); + }), + ).rejects.toThrow(); + }); +}); From d9d638912df0f8134ccb00fa276010fb434fd75a Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 19 Feb 2026 19:00:43 +0100 Subject: [PATCH 3/7] refactor: simplify disposer retrieval in using() function --- lib/using.ts | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/lib/using.ts b/lib/using.ts index b19ed369f..ff4460809 100644 --- a/lib/using.ts +++ b/lib/using.ts @@ -2,29 +2,6 @@ import { call } from "./call.ts"; import { resource } from "./resource.ts"; import type { Operation } from "./types.ts"; -type DisposableLike = { - [Symbol.dispose]?(): void; - [Symbol.asyncDispose]?(): PromiseLike | void; -}; - -function getDisposer(value: DisposableLike): () => PromiseLike | void { - let asyncDispose = value[Symbol.asyncDispose]; - - if (typeof asyncDispose === "function") { - return asyncDispose.bind(value); - } - - let dispose = value[Symbol.dispose]; - - if (typeof dispose === "function") { - return dispose.bind(value); - } - - throw new TypeError( - "using() value must implement Symbol.dispose or Symbol.asyncDispose", - ); -} - /** * Bind a JavaScript disposable value to the current Effection scope. * @@ -51,13 +28,23 @@ function getDisposer(value: DisposableLike): () => PromiseLike | void { export function* using( value: T, ): Operation { - let disposer = getDisposer(value); + let disposer = Symbol.asyncDispose in value + ? value[Symbol.asyncDispose] + : Symbol.dispose in value + ? value[Symbol.dispose] + : undefined; + + if (!disposer) { + throw new TypeError( + "using() value must implement Symbol.dispose or Symbol.asyncDispose", + ); + } return yield* resource(function* (provide) { try { yield* provide(value); } finally { - yield* call(() => disposer()); + yield* call(() => disposer.bind(value)()); } }); } From f20b946ab2a63c1969789ea6d8ca8b81f472b824 Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 19 Feb 2026 19:01:33 +0100 Subject: [PATCH 4/7] refactor: move resource classes to the end of the test file for better organization --- test/using.test.ts | 88 +++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/test/using.test.ts b/test/using.test.ts index ccd6944cb..282da6484 100644 --- a/test/using.test.ts +++ b/test/using.test.ts @@ -1,50 +1,6 @@ import { createScope, run, suspend, using } from "../mod.ts"; import { describe, expect, it } from "./suite.ts"; -class Resource { - value = 100; - isDisposed = false; - - getValue() { - if (this.isDisposed) { - throw new Error("Resource is disposed"); - } - return this.value; - } - - [Symbol.dispose]() { - this.isDisposed = true; - } -} - -class AsyncResource { - value = 100; - isDisposed = false; - - getValue() { - if (this.isDisposed) { - throw new Error("Resource is disposed"); - } - return this.value; - } - - async [Symbol.asyncDispose]() { - await Promise.resolve(void 0); - this.isDisposed = true; - } -} - -class DelayedAsyncResource { - isDisposed = false; - disposeStarted = false; - - async [Symbol.asyncDispose]() { - this.disposeStarted = true; - await new Promise((resolve) => setTimeout(resolve, 20)); - this.isDisposed = true; - } -} - describe("using", () => { it("should dispose sync disposable value without the native 'using' keyword", async () => { let value: number | undefined; @@ -126,3 +82,47 @@ describe("using", () => { ).rejects.toThrow(); }); }); + +class Resource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + [Symbol.dispose]() { + this.isDisposed = true; + } +} + +class AsyncResource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + async [Symbol.asyncDispose]() { + await Promise.resolve(void 0); + this.isDisposed = true; + } +} + +class DelayedAsyncResource { + isDisposed = false; + disposeStarted = false; + + async [Symbol.asyncDispose]() { + this.disposeStarted = true; + await new Promise((resolve) => setTimeout(resolve, 20)); + this.isDisposed = true; + } +} From 0cfe2e9bf00bea4939115600b37553aaeb15e13b Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 19 Feb 2026 19:01:33 +0100 Subject: [PATCH 5/7] refactor: move resource classes to the end of the test file for better organization --- test/using.test.ts | 98 +++++++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/test/using.test.ts b/test/using.test.ts index ccd6944cb..d7b1cc064 100644 --- a/test/using.test.ts +++ b/test/using.test.ts @@ -1,50 +1,6 @@ import { createScope, run, suspend, using } from "../mod.ts"; import { describe, expect, it } from "./suite.ts"; -class Resource { - value = 100; - isDisposed = false; - - getValue() { - if (this.isDisposed) { - throw new Error("Resource is disposed"); - } - return this.value; - } - - [Symbol.dispose]() { - this.isDisposed = true; - } -} - -class AsyncResource { - value = 100; - isDisposed = false; - - getValue() { - if (this.isDisposed) { - throw new Error("Resource is disposed"); - } - return this.value; - } - - async [Symbol.asyncDispose]() { - await Promise.resolve(void 0); - this.isDisposed = true; - } -} - -class DelayedAsyncResource { - isDisposed = false; - disposeStarted = false; - - async [Symbol.asyncDispose]() { - this.disposeStarted = true; - await new Promise((resolve) => setTimeout(resolve, 20)); - this.isDisposed = true; - } -} - describe("using", () => { it("should dispose sync disposable value without the native 'using' keyword", async () => { let value: number | undefined; @@ -126,3 +82,57 @@ describe("using", () => { ).rejects.toThrow(); }); }); + +class Resource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + [Symbol.dispose]() { + this.isDisposed = true; + } +} + +class AsyncResource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + async [Symbol.asyncDispose]() { + await Promise.resolve(void 0); + this.isDisposed = true; + } +} + +class DelayedAsyncResource { + isDisposed = false; + disposeStarted = false; + + async [Symbol.asyncDispose]() { + this.disposeStarted = true; + + let id: number | undefined; + + try { + await new Promise((resolve) => { + id = setTimeout(resolve, 20); + }); + + this.isDisposed = true; + } finally { + if (id) clearTimeout(id); + } + } +} From 1a43856f7b8a89b0e795c52460c09e91a1d4b9be Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 19 Feb 2026 20:36:34 +0100 Subject: [PATCH 6/7] fix: correct disposer invocation in using() function --- lib/using.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/using.ts b/lib/using.ts index ff4460809..b298b4d7c 100644 --- a/lib/using.ts +++ b/lib/using.ts @@ -44,7 +44,7 @@ export function* using( try { yield* provide(value); } finally { - yield* call(() => disposer.bind(value)()); + yield* call(() => disposer.call(value)); } }); } From d855861db9db6e6fc4aec14681ddbcaefebbbaad Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 19 Feb 2026 22:22:14 +0100 Subject: [PATCH 7/7] fix: rename disposer variable to dispose for clarity --- lib/using.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/using.ts b/lib/using.ts index b298b4d7c..9d39416da 100644 --- a/lib/using.ts +++ b/lib/using.ts @@ -28,13 +28,13 @@ import type { Operation } from "./types.ts"; export function* using( value: T, ): Operation { - let disposer = Symbol.asyncDispose in value + let dispose = Symbol.asyncDispose in value ? value[Symbol.asyncDispose] : Symbol.dispose in value ? value[Symbol.dispose] : undefined; - if (!disposer) { + if (!dispose) { throw new TypeError( "using() value must implement Symbol.dispose or Symbol.asyncDispose", ); @@ -44,7 +44,7 @@ export function* using( try { yield* provide(value); } finally { - yield* call(() => disposer.call(value)); + yield* call(() => dispose.call(value)); } }); }