Deterministic cleanup helpers for TypeScript/JavaScript based on ECMAScript Explicit Resource Management (using / await using) and DisposableStack.
The package provides two base classes:
Destructor— synchronous cleanupAsyncDestructor— asynchronous cleanup
And two ready-to-use scope guards:
ScopeGuard— run a callback on scope exit (sync)AsyncScopeGuard— run an async callback on scope exit (async)
When an instance is disposed, all destructors declared in the class inheritance chain are invoked automatically (from the most-derived class to the base class).
- ✅ Works with
using/await using(and manualSymbol.dispose/Symbol.asyncDisposecalls) - ✅ Automatic destructor chaining across inheritance (
C -> B -> A) - ✅ Built-in
DisposableStack/AsyncDisposableStack(via thedisposablestackpolyfill) - ✅ Scope guards for ad-hoc cleanup (
ScopeGuard/AsyncScopeGuard) - ✅ Small API surface, TypeScript-first typings
npm install resource-finalizer- TypeScript: enable the disposable APIs in your
tsconfig.json:
- Runtime: this library imports
disposablestack/autointernally to ensureDisposableStack/AsyncDisposableStackexist onglobalThis.
using/await usingare part of the Explicit Resource Management proposal and require TypeScript (or a runtime) that understands this syntax.
import { Symbols, Destructor } from 'resource-finalizer';
class A extends Destructor {
public constructor() {
super();
console.log('[A] constructor');
}
public [Symbols.destructor](): void {
console.log('[A] destructor');
}
}
class B extends A {
public constructor() {
super();
console.log('[B] constructor');
}
public [Symbols.destructor](): void {
console.log('[B] destructor');
}
}
class C extends B {
public constructor() {
super();
console.log('[C] constructor');
}
public [Symbols.destructor](): void {
console.log('[C] destructor');
}
}
{
using instance = new C();
console.log('End scope');
}
console.log('End code');Expected order:
- constructors:
A -> B -> C - destructors (on scope exit):
C -> B -> A
import { Symbols, AsyncDestructor } from 'resource-finalizer';
class A extends AsyncDestructor {
public constructor() {
super();
console.log('[Async][A] constructor');
}
public async [Symbols.asyncDestructor](): Promise<void> {
console.log('[Async][A] destructor');
}
}
class B extends A {
public constructor() {
super();
console.log('[Async][B] constructor');
}
public async [Symbols.asyncDestructor](): Promise<void> {
console.log('[Async][B] destructor');
}
}
class C extends B {
public constructor() {
super();
console.log('[Async][C] constructor');
}
public async [Symbols.asyncDestructor](): Promise<void> {
console.log('[Async][C] destructor');
}
}
(async () => {
{
await using instance = new C();
console.log('[Async] End scope');
}
console.log('[Async] End code');
})().catch(console.error);If you only need “run this cleanup when the scope ends”, you don’t have to define a new class.
Use ScopeGuard / AsyncScopeGuard — small wrappers around Destructor / AsyncDestructor that execute a user-provided finalizer when disposed.
import { ScopeGuard } from 'resource-finalizer';
{
using _ = new ScopeGuard(() => {
console.log('cleanup runs on scope exit');
});
console.log('work');
}
console.log('after scope');import { AsyncScopeGuard } from 'resource-finalizer';
import { promises as fs } from 'node:fs';
async function demo() {
const path = './tmp.txt';
await fs.writeFile(path, 'hello');
await using _ = new AsyncScopeGuard(async () => {
await fs.rm(path, { force: true });
});
// use the file...
}import { ScopeGuard, AsyncScopeGuard } from 'resource-finalizer';
const g = new ScopeGuard(() => console.log('cleanup'));
try {
// work...
} finally {
g[Symbol.dispose]();
}
async function demoAsync() {
const g = new AsyncScopeGuard(async () => console.log('async cleanup'));
try {
// work...
} finally {
await g[Symbol.asyncDispose]();
}
}Because scope guards implement Disposable / AsyncDisposable, you can register them in a stack:
import { Symbols, Destructor, ScopeGuard } from 'resource-finalizer';
class Service extends Destructor {
public constructor() {
super();
this[Symbols.disposableStack].use(
new ScopeGuard(() => console.log('Service stopped'))
);
}
public [Symbols.destructor](): void {
// other cleanup...
}
}import { Symbols, Destructible, createDisposableStack, callDestructorsChain } from 'resource-finalizer';
class SomeBaseClass {}
/**
* We need to add destructor support to a class that is already a derived class.
* To do this, you need to implement the following yourself
*
* - [Symbols.disposableStack]: DisposableStack;
* - [Symbols.callDestructorsChain](): void;
* - [Symbols.destructor](): void;
* - [Symbol.dispose](): void;
*/
class A extends SomeBaseClass implements Destructible {
public [Symbols.disposableStack] = createDisposableStack();
public [Symbol.dispose](): void {
this[Symbols.disposableStack].dispose();
}
public constructor() {
super();
this[Symbols.disposableStack].defer(() => {
this[Symbols.callDestructorsChain]();
});
console.log('[A] constructor');
}
public [Symbols.callDestructorsChain](): void {
callDestructorsChain(this);
}
public [Symbols.destructor](): void {
console.log('[A] destructor');
}
}
class B extends A {
public constructor() {
super();
console.log('[B] constructor');
}
public [Symbols.destructor](): void {
console.log('[B] destructor');
}
}
class C extends B {
public constructor() {
super();
console.log('[C] constructor');
}
public [Symbols.destructor](): void {
console.log('[C] destructor');
}
}
{
using instance = new C();
console.log('End scope');
}
console.log('End code');Every Destructor instance owns a DisposableStack accessible via a symbol key:
import { Symbols, Destructor } from 'resource-finalizer';
class FileHandle extends Destructor {
private fd: number;
public constructor(fd: number) {
super();
this.fd = fd;
// Register cleanup actions.
this[Symbols.disposableStack].defer(() => {
// close(fd)
});
}
public [Symbols.destructor](): void {
// Additional destructor logic (logging, metrics, invariants, etc.)
}
}For async cleanup, use AsyncDestructor and Symbols.asyncDisposableStack.
The destructor chain is discovered by walking the prototype chain and calling destructors that are defined directly on each prototype.
That means:
- ✅ Define destructors as class methods:
public [Symbols.destructor](){...} - ❌ Don’t assign destructors as instance fields (e.g.
this[Symbols.destructor] = () => {}), because they won’t be found by the chain walker. - ❌ Don’t call
super[Symbols.destructor]()manually — the base destructors are called automatically and you’d double-run them.
A holder of unique symbols used as keys:
Symbols.destructorSymbols.asyncDestructorSymbols.disposableStackSymbols.asyncDisposableStackSymbols.callDestructorsChainSymbols.asyncCallDestructorsChain
- Implements
Disposable([Symbol.dispose]()) - Provides an instance
DisposableStackatthis[Symbols.disposableStack] - Requires you to implement
public abstract [Symbols.destructor](): void
- Implements
AsyncDisposable([Symbol.asyncDispose]()) - Provides an instance
AsyncDisposableStackatthis[Symbols.asyncDisposableStack] - Requires you to implement
public abstract [Symbols.asyncDestructor](): Promise<void>
- Extends
Destructor - Constructor:
new ScopeGuard(() => void) - Executes the finalizer on
[Symbol.dispose]()/usingscope exit
- Extends
AsyncDestructor - Constructor:
new AsyncScopeGuard(() => Promise<void>) - Executes the finalizer on
[Symbol.asyncDispose]()/await usingscope exit
DestructibleAsyncDestructible
DisposableStackAsyncDisposableStack
createDisposableStack(): DisposableStackcreateAsyncDisposableStack(): AsyncDisposableStackcallDestructorsChain(obj: object): voidasyncCallDestructorsChain(obj: object): Promise<void>
MIT
{ "compilerOptions": { "lib": ["ES2022", "ESNext.Disposable"] } }