Deterministic cleanup in TypeScript with await using and DisposableStack

Three nested try/finally blocks on the left and the same three resources as three await using lines on the right, with an upward arrow marking each line's closing position: the span is closed first, then the connection, then the temp folder
COMPANION REPO · LIVE FROM GITHUB

A helper that seeds test fixtures usually has to give something back when it's done. Mine had two: a temporary folder on disk, and an open database connection. Add a trace span and that's three things to close, and this is the shape I kept writing.

export async function seedWithTryFinally(trace: Trace, fail: boolean): Promise<void> {
  const folder = await TempFolder.create(trace);
  try {
    const db = new FakeDbConnection(trace, "db");
    try {
      const span = new Span(trace, "seed");
      try {
        await folder.write("seed.sql", "insert into t values (1);\n");
        if (fail) throw new Error("seed script failed on row 4");
        trace.add("body done");
      } finally {
        span[Symbol.dispose]();
      }
    } finally {
      await db[Symbol.asyncDispose]();
    }
  } finally {
    await folder[Symbol.asyncDispose]();
  }
}

Three lines do the work and the rest is scaffolding. It's correct, and part of being correct is that finally blocks unwind inside-out, so the connection closes before the folder it was working in is deleted. Nothing in the code says that; the indentation does. Adding a fourth resource means reading all three closing lines to work out where the new one goes.

TypeScript 5.2 added using and await using declarations, and Node has run them without a flag since 24.0.0. The same helper, same order of events:

export async function seedWithUsing(trace: Trace, fail: boolean): Promise<void> {
  await using folder = await TempFolder.create(trace);
  await using db = new FakeDbConnection(trace, "db");
  await using span = new Span(trace, "seed");

  await folder.write("seed.sql", "insert into t values (1);\n");
  if (fail) throw new Error("seed script failed on row 4");
  trace.add("body done");
}

A using declaration registers the value's cleanup method with the block that declares it. When control leaves that block, by running off the end, by return, by break, by continue or by throwing, the registered resources are disposed in reverse order of declaration. using calls Symbol.dispose and moves on; await using looks for Symbol.asyncDispose first and awaits it. If you've written C# 8 or later, it's the using declaration from there, reverse order included.

OPEN TOP TO BOTTOM · CLOSE BOTTOM TO TOP · THE ERROR LEAVES LAST
await using folder = await TempFolder.create();
await using db = new Connection();
await using span = new Span('seed');
await seed(db, folder);
}
Error: seed script failed on row 4   thrown to the caller, after every close has run
no try, no finally: the closes are registered by the declarations and run in reverse at the closing brace, on the failing path exactly as on the clean one

Writing the disposable

There's nothing special in the object. A method under a well-known symbol, and an interface to say so. Both classes are trimmed here to the cleanup method, plus the one call on the connection that shows whether it ran; the constructors, the name and path fields, #trace and TempFolder.create are in the demo:

export class FakeDbConnection implements AsyncDisposable {
  #open = true;

  async query(sql: string): Promise<string> {
    if (!this.#open) throw new Error(`${this.name} is closed`);
    return `${this.name}: ${sql}`;
  }

  async [Symbol.asyncDispose](): Promise<void> {
    this.#open = false;
    this.#trace.add(`close ${this.name}`);
  }
}

export class TempFolder implements AsyncDisposable {
  async [Symbol.asyncDispose](): Promise<void> {
    await rm(this.path, { recursive: true, force: true });
    this.#trace.add("remove temp folder");
  }
}

Disposable and AsyncDisposable are the two interfaces. They live in lib.esnext.disposable.d.ts, which is the tsconfig detail that usually trips people up, and it's the last section here.

The closing order, in a test

Every resource in the demo writes into a shared Trace, so a test can compare the whole sequence:

test("three await using lines close in reverse order", async () => {
  const trace = new Trace();

  {
    await using folder = await TempFolder.create(trace);
    await using db = new FakeDbConnection(trace, "db");
    await using span = new Span(trace, "seed");

    assert.equal(await db.query("select 1"), "db: select 1");
    assert.ok(await folder.exists());
    assert.equal(span.label, "seed");
    trace.add("body done");
  }

  assert.deepEqual(trace.events, [
    "create temp folder",
    "open db",
    "begin seed",
    "body done",
    "end seed",
    "close db",
    "remove temp folder",
  ]);
});

Note the bare block. The resources close at its closing brace, not when the function returns, which is the part that makes this usable inside a loop.

The throwing path produces the same list with body done missing, and the error the body threw is what reaches the caller. Both versions of the seeding helper above are in the demo, and two tests run them side by side and compare their traces, once with a clean body and once with a throwing one:

✔ both versions produce the same trace when nothing fails (5.9441ms)
✔ both versions produce the same trace when the body throws (2.7273ms)
✔ everything still closes when the body throws (4.6565ms)
✔ the same code with no error closes in the same order (1.5345ms)
✔ an early return closes everything too (0.2052ms)
✔ a resource never reached is never closed (0.122ms)

A using declaration the runtime never reached registered nothing, so a throw halfway down a block of six resources closes the three that were opened and leaves the rest alone.

What the declaration accepts

Three things caught me out the first time.

await using is happy with a synchronous disposable. Span above only implements Symbol.dispose, and await using span = new Span(trace, "seed") compiles and runs: the lookup tries Symbol.asyncDispose, falls back to Symbol.dispose, calls it, and still awaits once before the block ends. It does not await a promise a sync Symbol.dispose returns, so async cleanup belongs behind Symbol.asyncDispose. A timer and a connection can sit in the same list of declarations without one of them needing a fake async wrapper.

null and undefined are legal and are skipped. That's what lets an optional resource stay on one line without a branch around the whole block:

await using nothing = null;
await using alsoNothing = undefined;
using stillNothing = null;

An object with no cleanup method is a TypeError, thrown at the declaration rather than at the end of the block. On Node 26.1.0 the message is TypeError: Symbol(Symbol.dispose) is not a function. Compiled down by TypeScript it's TypeError: Object not disposable. instead; the helpers carry their own wording for every error they raise, and the one worth knowing about is the SuppressedError message further down.

An unknown number of resources

One declaration holds one resource. When the count is decided at run time, the holder is DisposableStack, or AsyncDisposableStack for the awaiting kind:

await using stack = new AsyncDisposableStack();
const connections = shards.map((shard) =>
  stack.use(new FakeDbConnection(trace, shard)),
);

use registers a disposable and hands it straight back, so it reads inline. defer takes a bare callback for cleanup that isn't attached to an object. adopt takes a value plus a function that disposes it, which covers everything that never got a Symbol.dispose of its own: a file descriptor, a setInterval handle, a third-party client with a .destroy(). All three land in one list and unwind in reverse, so use, defer and adopt calls can be interleaved freely.

move is the method I would have wanted years ago. A factory that opens several things and returns them as one unit has a window where it's half built, and that window is where a leak usually starts. With a stack, the factory owns the resources until it succeeds, then hands ownership out:

async function openPool(): Promise<AsyncDisposableStack> {
  await using stack = new AsyncDisposableStack();
  stack.use(new FakeDbConnection(trace, "primary"));
  stack.use(new FakeDbConnection(trace, "replica"));
  return stack.move();
}

If the second connection throws, the first one closes on the way out. If nothing throws, move transfers everything into a fresh stack, marks the original disposed, and the await using on the way out has nothing left to do. The caller then writes await using pool = await openPool() and gets both connections closed at the end of its own block, replica first.

When cleanup itself throws

This is the corner that makes try/finally awkward to hand-roll: a finally block that throws replaces the error the body threw, and the real cause disappears. The disposal machinery keeps both in a SuppressedError. Field names first, because they're easy to read backwards:

await using broken = new BrokenConnection("db");   // its dispose throws
throw new Error("seed script failed on row 4");
{"name":"SuppressedError","message":"An error was suppressed during disposal","error":"could not close db","suppressed":"seed script failed on row 4"}

error is the most recent failure, which is the one from the cleanup. suppressed is the one it pushed aside, which is the original. I had them the other way round in my head. On its own, err.message says An error was suppressed during disposal and nothing else, and a log line that prints err.error.message and stops will tell you the connection failed to close and never mention the row that actually broke.

More than one failing cleanup nests the same way, newest error outermost. Three of them, disposed c then b then a, give a SuppressedError whose error is a's failure and whose suppressed is another SuppressedError holding b and c. A cleanup that throws on its own, with a clean body, is not wrapped at all: the plain error comes out, which means a catch cannot assume the shape of what it gets.

The one thing not to match on is the message. V8 on Node 26.1.0 produces An error was suppressed during disposal, while the helper TypeScript emits for a target below esnext ends it with a full stop:

{"message":"An error was suppressed during disposal"}
{"message":"An error was suppressed during disposal."}

Everything else on the object is the same, so match on error and suppressed and leave the message alone.

Switching it on

On TypeScript 5.9.3 the two settings that matter are the target and the lib:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "ESNext.Disposable"]
  }
}

Drop ESNext.Disposable and the same file stops compiling, with TS2318: Cannot find global type 'Disposable' and TS2550: Property 'dispose' does not exist on type 'SymbolConstructor'. Do you need to change your target library?.... The demo keeps a second tsconfig with that entry removed, and that build fails with those errors. A Node project may not notice any of this: @types/node carries a /// <reference lib="esnext.disposable" />, so installing it pulls the declarations in whatever the lib array says.

target decides the output, not whether the code compiles. Everything up to and including ES2024 emits TypeScript's __addDisposableResource and __disposeResources helpers inline; only esnext leaves using and await using as written. That is the state on 5.9.3, and it will move once TypeScript adds a target for the edition that ships the syntax.

For the runtime, MDN's compatibility data at the end of September 2026 has the whole set (using, await using, DisposableStack, AsyncDisposableStack, SuppressedError) unflagged from Node 24.0.0, Chrome and Edge 134, Firefox 141, Deno 2.2.10 and Bun 1.3.0. Two pieces work before that line: Deno runs the bare using statement from 1.37, and Bun has using, await using and SuppressedError from 1.0.23. In Deno the rest arrives together at 2.2.10, and in Bun the stacks come last at 1.3.0, so those are the two numbers to plan against. Safari has all of it in preview only, which means a browser bundle still wants the downlevel emit for a while. The proposal is a finished TC39 proposal and is listed for the 2027 edition of the language.

The demo is at github.com/gdhami-net/await-using-demo. npm run check runs 23 tests twice, once as TypeScript straight on Node and once against the tsc output at target: ES2022, and prints the SuppressedError from both paths so the message difference is visible. I ran all of it on Node v26.1.0 and TypeScript 5.9.3 on Windows 11; I haven't tried Linux or macOS.