TypeScript · lesson 24 of 25
Beginner
6Intermediate
16- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 Enums, and Why Node Won't Strip Them
- 22 Narrowing and Control Flow Analysis
- 23 Type-Checking JavaScript with JSDoc and checkJs
- 24 Resource Cleanup with using
- 25 TypeScript Interview Prep
Also part of TypeScript
3Also part of: JavaScript
Resource Cleanup with using
using and Symbol.dispose run cleanup in LIFO order, even on exceptions, with await using, DisposableStack, and the tsc target they need.
By SkillByExample · Published
Examples run on Node v24.18.1 and TypeScript 7.0.2, each as a standalone file in a folder whose package.json has "type": "module". npx tsc --noEmit --module nodenext --target esnext file.ts type-checks a file; node file.ts runs one — no tsx is needed anywhere in this lesson, because every construct here is plain JavaScript, not TypeScript-only syntax.
using and Symbol.dispose: Cleanup in Reverse Order
A using declaration is a const that also registers its value for disposal when the block ends. Disposal runs in the reverse order the variables were declared.
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
},
};
}
function run(): void {
using a = resource("a");
using b = resource("b");
console.log("working");
}
run();
console.log("done");
$ npx tsc --noEmit --module nodenext --target esnext using-basic.ts
$ node using-basic.ts
open a
open b
working
close b
close a
done
b closes before a, even though a was opened first — the same order a stack of finally blocks would unwind in, which is how using behaves. At target: esnext, which every command in this lesson uses, tsc doesn’t actually rewrite using into anything: the emitted JavaScript keeps the using keyword verbatim, and it’s Node’s own runtime support for it that does the disposing. (At an older target, tsc does downlevel it to a real try/finally-shaped helper — but that’s not what runs here.) Neither resource nor run calls [Symbol.dispose] directly either way; something else does it automatically as run returns.
Disposal Still Runs When the Block Throws
The point of using is that disposal happens on every exit path, not just the successful one.
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
},
};
}
function run(): void {
using a = resource("a");
using b = resource("b");
console.log("working");
throw new Error("something went wrong");
}
try {
run();
} catch (err) {
console.log(`caught: ${(err as Error).message}`);
}
$ npx tsc --noEmit --module nodenext --target esnext using-exception.ts
$ node using-exception.ts
open a
open b
working
close b
close a
caught: something went wrong
Both resources close, in the same reverse order as before, and the closes happen before the catch block runs — disposal is part of how the exception propagates out of run, not something that happens afterward.
The tsc Target using Needs
Compiling the very first example without --target esnext fails, even though node runs the identical file with no complaint.
$ npx tsc --noEmit --module nodenext using-basic.ts
error TS2318: Cannot find global type 'Disposable'.
using-basic.ts(2,11): error TS2550: Property 'dispose' does not exist on type 'SymbolConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.
using-basic.ts(8,13): error TS2550: Property 'dispose' does not exist on type 'SymbolConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.
Disposable, AsyncDisposable, and the dispose/asyncDispose members on Symbol, are declared in TypeScript’s esnext lib file — not in any dated es2022/es2023 lib, however recent, because Explicit Resource Management wasn’t part of a yearly ECMAScript edition when TypeScript added support for it. target: "esnext" (or lib including "esnext") is what pulls those declarations in; this is a compile-time-only gap, since node strips types and never looks at tsc’s lib files at all.
await using and Symbol.asyncDispose
An asynchronous resource — a database handle, a connection that needs to flush before closing — uses await using and Symbol.asyncDispose. tsc emits the statement unchanged, same as plain using; it’s Node, at runtime, that awaits each disposal before moving on.
interface AsyncResource {
[Symbol.asyncDispose](): Promise<void>;
}
function connection(name: string): AsyncResource {
console.log(`connect ${name}`);
return {
async [Symbol.asyncDispose]() {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`disconnect ${name}`);
},
};
}
async function run(): Promise<void> {
await using db = connection("db");
await using cache = connection("cache");
console.log("querying");
}
await run();
console.log("done");
$ npx tsc --noEmit --module nodenext --target esnext await-using.ts
$ node await-using.ts
connect db
connect cache
querying
disconnect cache
disconnect db
done
"done" only prints after both disconnects have finished — each await using disposal is awaited in turn, cache before db, before run() itself resolves. Swapping using for await using on a resource whose dispose method is synchronous works too; only an actually-async Symbol.asyncDispose needs it.
DisposableStack: Rolling Back Partial Setup
DisposableStack collects resources one at a time, so a function that opens several things can clean up only the ones it actually opened if something fails partway through — and hand the whole stack to its caller if everything succeeds.
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
},
};
}
function openPipeline(fail: boolean): DisposableStack {
using stack = new DisposableStack();
stack.use(resource("a"));
stack.use(resource("b"));
if (fail) {
throw new Error("setup failed partway");
}
return stack.move();
}
try {
openPipeline(true);
} catch (err) {
console.log(`caught: ${(err as Error).message}`);
}
$ npx tsc --noEmit --module nodenext --target esnext disposable-stack-fail.ts
$ node disposable-stack-fail.ts
open a
open b
close b
close a
caught: setup failed partway
stack is itself a using variable, so the throw disposes everything added to it so far — both a and b — on the way out, in reverse order, exactly like the plain using examples above. On the success path, stack.move() runs before any of that:
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
},
};
}
function openPipeline(fail: boolean): DisposableStack {
using stack = new DisposableStack();
stack.use(resource("a"));
stack.use(resource("b"));
if (fail) {
throw new Error("setup failed partway");
}
return stack.move();
}
try {
const good = openPipeline(false);
console.log("pipeline ready");
good.dispose();
} catch (err) {
console.log(`caught: ${(err as Error).message}`);
}
$ npx tsc --noEmit --module nodenext --target esnext disposable-stack.ts
$ node disposable-stack.ts
open a
open b
pipeline ready
close b
close a
move() returns a new DisposableStack holding the same resources and empties the original, so the local using stack has nothing left to dispose when openPipeline returns — ownership moved to good, and only good.dispose() closes anything, on the caller’s schedule.
Practice
1. Add a third resource, c, to run in using-basic.ts, declared after a and b. What order do the three closes print in?
Solution
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
},
};
}
function run(): void {
using a = resource("a");
using b = resource("b");
using c = resource("c");
console.log("working");
}
run();
console.log("done");
$ npx tsc --noEmit --module nodenext --target esnext practice-1.ts
$ node practice-1.ts
open a
open b
open c
working
close c
close b
close a
done
Reverse declaration order throughout: c, then b, then a — the same pattern three levels deep as it was with two.
2. Change run so it throws right after opening b, before a third resource c is ever declared. Confirm c is never opened and never disposed.
Solution
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
},
};
}
function run(shouldFail: boolean): void {
using a = resource("a");
using b = resource("b");
if (shouldFail) {
throw new Error("failed after opening b");
}
using c = resource("c");
}
try {
run(true);
} catch (err) {
console.log(`caught: ${(err as Error).message}`);
}
$ npx tsc --noEmit --module nodenext --target esnext practice-2.ts
$ node practice-2.ts
open a
open b
close b
close a
caught: failed after opening b
Only resources whose using declaration actually ran get disposed — c never appears anywhere in the output, because control never reached its declaration.
3. Add a third await using resource, queue, to run in await-using.ts. Confirm the three disconnects happen in reverse order, each one awaited before the next starts.
Solution
interface AsyncResource {
[Symbol.asyncDispose](): Promise<void>;
}
function connection(name: string): AsyncResource {
console.log(`connect ${name}`);
return {
async [Symbol.asyncDispose]() {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`disconnect ${name}`);
},
};
}
async function run(): Promise<void> {
await using db = connection("db");
await using cache = connection("cache");
await using queue = connection("queue");
console.log("querying");
}
await run();
console.log("done");
$ npx tsc --noEmit --module nodenext --target esnext practice-3.ts
$ node practice-3.ts
connect db
connect cache
connect queue
querying
disconnect queue
disconnect cache
disconnect db
done
queue, opened last, disconnects first — the same LIFO rule as the synchronous version, with each disconnect finishing (10ms each here) before the next one starts.
4. Make a’s [Symbol.dispose] throw, on top of run itself already throwing in the body. What does catch actually receive — the body’s error, the disposal error, or both?
Solution
interface Resource {
[Symbol.dispose](): void;
}
function resource(name: string, failOnDispose: boolean): Resource {
console.log(`open ${name}`);
return {
[Symbol.dispose]() {
console.log(`close ${name}`);
if (failOnDispose) {
throw new Error(`dispose of ${name} failed`);
}
},
};
}
function run(): void {
using a = resource("a", true);
using b = resource("b", false);
console.log("working");
throw new Error("something went wrong");
}
try {
run();
} catch (err) {
const e = err as SuppressedError;
console.log(`caught: ${e.message}`);
console.log(`error: ${(e.error as Error).message}`);
console.log(`suppressed: ${(e.suppressed as Error).message}`);
}
$ npx tsc --noEmit --module nodenext --target esnext practice-4.ts
$ node practice-4.ts
open a
open b
working
close b
close a
caught: An error was suppressed during disposal
error: dispose of a failed
suppressed: something went wrong
Both — wrapped in a SuppressedError, a built-in error type made for exactly this. b disposes cleanly, but a’s dispose throws while the original "something went wrong" is already in flight. Rather than silently losing one of the two, the runtime raises the later error — a’s disposal failure — as .error, and attaches the earlier one as .suppressed, so catch can still recover both instead of one replacing the other.