Mert Özen Further With Every Line
Frontend TypeScript Advanced — #09

using and await using in TypeScript: Resource Management with Symbol.dispose

Mert Özen Nov 3, 2026 7 min 2 views
using and await using in TypeScript: Resource Management with Symbol.dispose

We look at the TypeScript side of using and await using from TS 5.2: the Disposable types, the lib setting you need, the compiler's checks, DisposableStack, and what tsc's output means for Node and Safari.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

We open two resources, then throw an error in the middle of the block:

function track(name: string): Disposable {
  console.log(`open ${name}`);
  return {
    [Symbol.dispose]() {
      console.log(`close ${name}`);
    },
  };
}

try {
  using db = track("db");
  using cache = track("cache");
  throw new Error("boom");
} catch (error) {
  if (error instanceof Error) {
    console.log(`caught ${error.message}`);
  }
}

What does this print, and in which order? Do the close lines show up at all, and if so, before or after caught boom? And one more question: if you tried to compile this file with the series' standard settings, what would tsc say? We'll answer both in turn.

What Does using Solve?

In the last post of the JavaScript series we saw the Explicit Resource Management proposal and using. A quick reminder: when you open a file, a database connection or a timer, you write try/finally so you don't forget to close it. Two resources mean two nested try blocks, three mean three. using moves this job into the language: a variable declared with using is closed automatically however its block ends, by a normal return or by an error. The closing is done by the object's Symbol.dispose method.

TypeScript has supported this syntax since TS 5.2. We won't re-explain the JavaScript side in this post; we'll look at what TypeScript adds: the Disposable and AsyncDisposable types, the checks the compiler performs, and the JavaScript tsc produces. That last one matters especially when you think about Safari.

The first answer to the opening question: the console shows open db, open cache, close cache, close db and caught boom, in that order. Resources are closed in reverse order of opening, and that happens before the error reaches catch. Even when the block ends with an error, nothing leaks.

The lib Setting Comes First

The second part of the question: in this series we check code with --lib es2025,dom. With that setting, the code above doesn't compile. The first thing tsc says is:

error TS2318: Cannot find global type 'Disposable'.

Then comes TS2550 for Symbol.dispose, and the compiler suggests moving lib to esnext. The reason is that using isn't part of ES2025 yet; its types live in a separate lib file called esnext.disposable. Even a single using nothing = null; line gives the same TS2318, because using needs the global Disposable type behind it. The fix is to add that lib:

{
  "compilerOptions": {
    "lib": ["es2025", "esnext.disposable", "dom"]
  }
}

You could also just write "esnext"; then the types of other not-yet-standard APIs come along too. Adding only what you need tells anyone reading the tsconfig exactly which feature is new. We checked the rest of the examples in this post with this lib setting.

The Disposable Type and the Compiler's Checks

Disposable is a tiny interface: a single [Symbol.dispose](): void method. TypeScript checks that the value you give to using fits this interface or is null or undefined. If you misspell the closing method, or call it close out of old habit, it's caught before the code runs:

const connection = {
  close() {
    console.log("closed");
  },
};

function query() {
  using conn = connection; // Error
}
error TS2741: Property '[Symbol.dispose]' is missing in type '{ close(): void; }' but required in type 'Disposable'.

In plain JavaScript this mistake would only surface at runtime, as a TypeError. When you write your own resource classes, adding implements Disposable is a good habit as well; if you forget the method, you get an error on the class definition:

class TempFile implements Disposable {
  path: string;
  constructor(path: string) {
    this.path = path;
    console.log(`create ${path}`);
  }

  write(text: string) {
    console.log(`write "${text}" to ${this.path}`);
  }

  [Symbol.dispose]() {
    console.log(`delete ${this.path}`);
  }
}

function exportReport() {
  using file = new TempFile("/tmp/report.csv");
  file.write("id,total");
  return "exported";
}

console.log(exportReport());
// create /tmp/report.csv
// write "id,total" to /tmp/report.csv
// delete /tmp/report.csv
// exported

Pay attention to the order of the output: delete runs after the function returns but before the caller prints the return value. A using variable behaves like a const; if you try to reassign it with file = ..., you get TS2588, "Cannot assign to 'file' because it is a constant."

await using and AsyncDisposable

Some resources can't close synchronously: you need to send the server a message before closing the connection, or flush buffered data to disk. For that there's Symbol.asyncDispose and await using. The TypeScript counterpart is the AsyncDisposable interface:

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function connect(url: string): Promise<AsyncDisposable> {
  await wait(10);
  console.log(`connected to ${url}`);
  return {
    async [Symbol.asyncDispose]() {
      await wait(10);
      console.log(`disconnected from ${url}`);
    },
  };
}

async function sync() {
  await using conn = await connect("db://main");
  console.log("syncing");
}

sync().then(() => console.log("done"));
// connected to db://main
// syncing
// disconnected from db://main
// done

The two awaits on that line do different jobs. The one on the right, await connect(...), waits for the connection to open; the one on the left, await using, says we'll wait for the closing to finish at the end of the block. The promise returned by sync doesn't settle until the connection is closed, which is why done comes last. You can give await using a synchronous resource with Symbol.dispose, but not the other way around.

Several Resources with DisposableStack

If a resource has no Symbol.dispose of its own, or the number of resources isn't known up front, you use DisposableStack. We saw the JavaScript side already; in TypeScript all its methods are typed. For example, adopt passes the type of the value you give it straight through to the closing function:

function track(name: string): Disposable {
  console.log(`open ${name}`);
  return {
    [Symbol.dispose]() {
      console.log(`close ${name}`);
    },
  };
}

function startPolling(): DisposableStack {
  using stack = new DisposableStack();
  stack.adopt(setInterval(() => {}, 1000), (id) => {
    clearInterval(id);
    console.log("timer cleared");
  });
  stack.defer(() => console.log("polling stopped"));
  stack.use(track("socket"));
  return stack.move();
}

{
  using polling = startPolling();
  console.log("polling...");
}
// open socket
// polling...
// close socket
// polling stopped
// timer cleared

We didn't write the type of id in adopt's callback; TypeScript inferred it from setInterval's return type. The stack.move() pattern at the end of the function is worth noticing too: if setup is interrupted by an error halfway through, using stack cleans up everything added so far. If all goes well, move() transfers the resources to a new stack, the old stack becomes empty, and there's nothing left for it to close on the way out. Responsibility for closing now belongs to the caller.

What Does the Compiler Emit? Node and Safari

using isn't TypeScript-specific syntax; it's JavaScript syntax. Once the types are stripped, valid JavaScript remains, so unlike decorators it counts as erasable. Node 24 turned Explicit Resource Management on by default, so Node 26 runs the files in this post directly with node file.ts. On an older Node, the engine doesn't understand using; when we tried a local Node 22.2, output compiled with target: esnext failed with a SyntaxError on the using line.

This is where tsc's transformation comes in. When target is es2025, which is TypeScript 7's default, tsc rewrites using into older syntax. Here's the output for exportReport:

function exportReport() {
  const env_1 = { stack: [], error: void 0, hasError: false };
  try {
    const file = __addDisposableResource(env_1, new TempFile("/tmp/report.csv"), false);
    file.write("id,total");
    return "exported";
  }
  catch (e_1) {
    env_1.error = e_1;
    env_1.hasError = true;
  }
  finally {
    __disposeResources(env_1);
  }
}

A familiar try/finally, plus two helper functions added at the top of the file. This output ran fine on the same Node 22.2. Here's what that means for Safari: as we saw in the JavaScript series, Safari doesn't support using yet and can't parse a script that contains this syntax at all. Compiling to es2025 with tsc removes the syntax problem. The runtime problem remains, though: __addDisposableResource checks whether Symbol.dispose exists, and if it doesn't, it throws TypeError("Symbol.dispose is not defined."). And tsc doesn't transform DisposableStack at all; it only knows its type. If you have Safari users, you need a polyfill for these two. For SuppressedError, the helper brings its own fallback.

Common Pitfalls

The first is declaring a resource with using and returning it from the function. By the time the value reaches the caller, the resource is already closed. If you want to hand a resource out, either don't use using or switch to the DisposableStack and move() pattern.

The second is trying to destructure. If you write using { length } = resource;, tsc says TS1492, "'using' declarations may not have binding patterns." Keep the resource in a variable and read its properties afterwards.

The third is using await using in a function that isn't async. tsc says TS2852: it's only allowed in async functions and at the top level of modules.

The fourth is assuming the runtime is ready because you changed the lib. esnext.disposable only brings the types. Check where your code will run: Node 26 and current Chrome or Firefox are fine; Safari needs a polyfill and an es2025 target.

A Small Experiment

★☆☆ Predict whether it compiles: function f() { using x = 42; }. If it errors, read the message and note which values are allowed; then replace 42 with undefined and try again.

★★☆ Turn TempFile into an AsyncTempFile meant for await using: inside Symbol.asyncDispose it should wait 10 ms and then delete the file. Write implements AsyncDisposable, then deliberately name the method Symbol.dispose and see what the compiler says.

★★★ Compile this post's exportReport example once with --target es2025 and once with --target esnext, and compare the two outputs. Which one could you run in Safari, and what else would you need to run it?

In the last two posts we dealt with features that touch runtime. In the next post we go back to the type level entirely: by solving the easy puzzles from type-challenges, we'll put the conditional, mapped and recursive types from this series to work together.