@yukiakai/disposable-stack
v1.1.1
Published
Strict resource lifecycle management for JavaScript & TypeScript.
Maintainers
Readme
Disposable Stack
Strict resource lifecycle management for JavaScript & TypeScript. Inspired by RAII and the TC39 DisposableStack proposal — with strong ownership guarantees.
Features
- LIFO cleanup (stack semantics)
- Strict lifecycle enforcement
- Explicit sync / async separation
- Deterministic disposal
- Fire-and-forget async support (explicit)
- Structured error handling
- Designed for correctness over convenience
Installation
npm install @yukiakai/disposable-stackUsage
Sync version
import { DisposableStack } from '@yukiakai/disposable-stack';
const stack = new DisposableStack();
stack.defer(() => {
console.log('cleanup A');
});
stack.defer(() => {
console.log('cleanup B');
});
stack.dispose();
// Output:
// cleanup B
// cleanup ASync + async cleanup (fire-and-forget)
const stack = new DisposableStack();
stack.deferAsync(async () => {
await db.close();
});
stack.dispose(); // does NOT waitAsync cleanup runs in the background.
Async version
import { AsyncDisposableStack } from '@yukiakai/disposable-stack';
const stack = new AsyncDisposableStack();
stack.defer(async () => {
await db.close();
});
await stack.dispose();API
DisposableStack
add(handle: DisposableHandle): handle
Add a disposable resource.
If the stack is already disposed, the handle will be disposed immediately.
defer(fn: () => void): DisposableHandle
Wrap a function into a disposable and add it to the stack.
deferAsync(fn: () => Promise<void>): DisposableHandle
Register an async cleanup function.
- Runs in fire-and-forget mode
- Does NOT block
dispose() - Errors are forwarded to onDeferredAsyncError handler
setOnDeferredAsyncError(fn: (error: DeferredAsyncError) => void): this
Set the handler for async (fire-and-forget) errors.
Default behavior: throws (errors are not silently ignored)
dispose(): void
Dispose all resources in reverse order (LIFO).
Strict behavior: Calling
dispose()more than once will throwDisposeCalledMultipleTimesError.
AsyncDisposableStack
add(handle: AsyncDisposableHandle): handle
Add a resource.
Throws if the stack is already disposed.
addAsync(handle: AsyncDisposableHandle): Promise<handle>
Add a resource, or dispose it immediately if the stack is already disposed.
defer(fn: () => Promise<void>): AsyncDisposableHandle
Wrap an async function into a disposable.
deferAsync(fn: () => Promise<void>): Promise<AsyncDisposableHandle>
Async-safe version of defer.
dispose(): Promise<void>
Dispose all resources in reverse order, awaiting each one.
Strict behavior: Calling
dispose()more than once will throwDisposeCalledMultipleTimesError.
Re-entrant Dispose
Calling dispose() inside a disposal handler is considered a lifecycle violation.
const stack = new DisposableStack();
stack.defer(() => {
stack.dispose(); // re-entrant
});
stack.dispose(); // throws DisposeCalledMultipleTimesErrorBehavior
- The error is thrown immediately
- Remaining disposables are NOT executed
- The stack is considered invalid and terminated
Behavior Differences
| Case | DisposableStack | AsyncDisposableStack |
| ---------------------- | ------------------- | -------------------- |
| dispose() | sync | async (await) |
| async cleanup | fire-and-forget | awaited |
| add() after disposed | dispose immediately | throws |
| multiple dispose() | throws | throws |
| error propagation | handler-based | throws |
Important Notes
1. LIFO Order
Resources are disposed in reverse order:
A → B → C (added)
C → B → A (disposed)2. Async Cleanup (Sync Stack)
deferAsync does not wait:
stack.deferAsync(async () => {
await cleanup();
});
stack.dispose(); // returns immediatelyIf you need guaranteed completion, use
AsyncDisposableStack.
3. Error Handling
Sync errors are collected and thrown as
DisposeErrorAsync (fire-and-forget) errors are forwarded to:
onDeferredAsyncError
Errors are never silently ignored
4. Strict Lifecycle Model
This library enforces a single-owner lifecycle:
create → use (shared) → dispose (once)- Only the owner should call
dispose() - Consumers should never terminate the lifecycle
Violations will result in runtime errors.
5. No Idempotent Dispose
Unlike many libraries, dispose() is not idempotent.
stack.dispose();
stack.dispose(); // throwsThis is intentional — to surface lifecycle design errors early.
When to use which?
Use
DisposableStackfor:- synchronous cleanup
- non-critical async (fire-and-forget)
Use
AsyncDisposableStackfor:- guaranteed async cleanup
- transactional / critical resources
Example
const stack = new AsyncDisposableStack();
const conn = await db.connect();
stack.defer(async () => {
await conn.close();
});
try {
// do work
} finally {
await stack.dispose(); // must be called exactly once
}Helpers
import { createAsyncDisposable } from '@yukiakai/disposable-stack';
const handle = createAsyncDisposable(async () => {
await cleanup();
});Errors
DisposeCalledMultipleTimesError
Thrown when dispose() is called more than once.
try {
stack.dispose();
stack.dispose();
} catch (e) {
// DisposeCalledMultipleTimesError
}Why not just use try/finally?
You can — but this library is useful when:
- managing multiple resources
- dynamic resource creation
- enforcing strict lifecycle ownership
- preventing hidden cleanup bugs
License
MIT
