relinquish
v0.1.0
Published
Adapters that turn callbacks, event listeners, timers, and AbortControllers into `using`-ready disposables. Adopt Explicit Resource Management with the APIs you already have. Zero deps.
Maintainers
Readme
relinquish
Adapters that turn callbacks, event listeners, timers, and AbortControllers into using-ready disposables. Adopt Explicit Resource Management with the APIs you already have. Zero deps.
The problem
Manual teardown is fragile: a forgotten clearTimeout, a listener that's never unbound, an AbortController that's never aborted. Each is a leak or a zombie callback. try/finally spreads cleanup across the function and is easy to skip under early returns or exceptions.
TypeScript 5.2 and modern Node ship using/await using and Symbol.dispose/Symbol.asyncDispose for deterministic cleanup — but almost nothing in the ecosystem exposes them yet. Timers, listeners, controllers, and ad-hoc teardown handles all still need manual cleanup.
Install
npm install relinquish
# or: pnpm add relinquish / yarn add relinquishRequires Node >=20.11 and TypeScript with lib: ["esnext"] (includes esnext.disposable).
Use
import { listener, timer, aborter } from "relinquish";
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
using sub = listener(emitter, "data", onData);
using t = timer(setTimeout(tick, 1000));
using ac = aborter();
fetch(url, { signal: ac.signal });
// at end of scope: listener removed, timer cleared, controller aborted.Compose multiple disposables with a stack — reverse-order, exactly once, errors aggregated:
import { stack, effect } from "relinquish";
using s = stack();
s.defer(() => closeDb());
s.use(effect(() => acquireLock(), (lock) => releaseLock(lock)));
// at end of scope: lock released, then db closed.API
Disposable_ / AsyncDisposable_
interface Disposable_ { [Symbol.dispose](): void }
interface AsyncDisposable_ { [Symbol.asyncDispose](): Promise<void> }Minimal disposable shapes. The underscore avoids colliding with the TS lib's Disposable.
effect(dispose) / effect(setup, dispose)
function effect(dispose: () => void): Disposable_;
function effect<T>(setup: () => T, dispose: (value: T) => void): Disposable_ & { value: T };Wrap a teardown callback. The two-arg form runs setup() immediately, exposes .value, and passes it to dispose(value) on cleanup. Idempotent.
asyncEffect(dispose)
function asyncEffect(dispose: () => Promise<void>): AsyncDisposable_;Async analogue of effect. For setup+dispose pairs, compose via asyncStack().defer(...).
listener(target, type, handler, options?)
Bind handler to type on target. Disposing calls the matching unbind. target is structural and auto-detected at registration: any object with addEventListener/removeEventListener (DOM EventTarget, AbortSignal, ...) OR on/off (Node EventEmitter). handler is any callable. options (optional) is passed through to both add and remove — required to unbind the right listener.
Returns Disposable_.
timer(id) / interval(id)
function timer(id: ReturnType<typeof setTimeout>): Disposable_;
function interval(id: ReturnType<typeof setInterval>): Disposable_;Wrap a timer id. Disposing calls clearTimeout / clearInterval.
aborter(reason?)
function aborter(reason?: unknown): AbortController & Disposable_;Returns a real AbortController augmented with [Symbol.dispose] that calls abort(reason). The .signal is standard; reason is forwarded on dispose.
stack() / asyncStack()
function stack(): DisposableStackLike;
function asyncStack(): AsyncDisposableStackLike;
interface DisposableStackLike extends Disposable_ {
use<T extends Disposable_>(d: T): T;
defer(fn: () => void): void;
move(): DisposableStackLike;
}Aggregate disposables. Disposal runs in reverse registration order, exactly once. On multiple disposal errors, throws SuppressedError(latest, AggregateError(rest)) when available, else AggregateError(all). move() returns a new stack that takes ownership; the original becomes disposed — subsequent use/defer/move on it throw. Prefers the platform's native DisposableStack / AsyncDisposableStack when present; falls back to a polyfill.
Non-goals
What relinquish does NOT do:
- Does NOT reimplement the platform's
DisposableStack. Prefers native when present. - Does NOT pool resources or reference-count them.
- Does NOT add framework hooks or decorators.
- Does NOT replace
try/finallyfor non-disposable cleanup.
TypeScript note
Full type declarations ship in dist/. If you have noUnusedLocals enabled, prefix unused using bindings with _ (e.g., using _sub = ...) — TS does not exempt using declarations from the unused-locals check.
License
MIT
