Skip to content
Open
1 change: 1 addition & 0 deletions lib/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
50 changes: 50 additions & 0 deletions lib/using.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { call } from "./call.ts";
import { resource } from "./resource.ts";
import type { Operation } from "./types.ts";

/**
* 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<T extends Disposable | AsyncDisposable>(
value: T,
): Operation<T> {
let dispose = Symbol.asyncDispose in value
? value[Symbol.asyncDispose]
: Symbol.dispose in value
? value[Symbol.dispose]
: undefined;

if (!dispose) {
throw new TypeError(
"using() value must implement Symbol.dispose or Symbol.asyncDispose",
);
}

return yield* resource<T>(function* (provide) {
try {
yield* provide(value);
} finally {
yield* call(() => dispose.call(value));
}
});
}
138 changes: 138 additions & 0 deletions test/using.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { createScope, run, suspend, using } from "../mod.ts";
import { describe, expect, it } from "./suite.ts";

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<void>((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();
});
});

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<void>((resolve) => {
id = setTimeout(resolve, 20);
});

this.isDisposed = true;
} finally {
if (id) clearTimeout(id);
}
}
}
Loading