Skip to main content
TypeScript · lesson 24 of 25
TypeScript intermediate Lesson 24 of 25

Also 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

typescript using resource-management

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.

Frequently Asked Questions

What does using actually do?
using declares a variable, same as const, but also registers the value for disposal at the end of the enclosing block. When the block exits, normally or via an exception, every using variable declared in it has its Symbol.dispose method called, in the reverse order the variables were declared.
Does tsc need a special option for using?
Yes. The Disposable and AsyncDisposable global types, and the dispose methods on Symbol, live in TypeScript's esnext lib file. Without target or lib set to esnext (or a future dated release that includes it), tsc reports one global TS2318 for the missing Disposable type, plus a TS2550 at each place Symbol.dispose or Symbol.asyncDispose is actually referenced — not at the using statements themselves, which don't name Symbol directly.
Does Node need a flag to run using?
Not on Node 24.18.1 — using, await using, Symbol.dispose, Symbol.asyncDispose, DisposableStack and AsyncDisposableStack are all available with no flag, in a plain .js file as well as in a .ts file run with Node's built-in type stripping. Check node --version if you're on an older release; this landed as a shipped, unflagged feature only recently.
What is DisposableStack for?
It lets a function collect several resources before deciding whether setup succeeded. If something fails partway, disposing the stack cleans up everything added so far in reverse order. If setup succeeds, stack.move() transfers ownership to a new DisposableStack the caller controls, without disposing anything — the local using declaration then disposes nothing, because move() leaves the original stack empty.
What is the difference between using and await using?
using calls a synchronous Symbol.dispose and does not pause execution. await using calls an asynchronous Symbol.asyncDispose and awaits its promise before continuing, so cleanup that needs to flush a buffer or close a network connection can finish before the next line runs. await using can only appear inside an async function.