A using declaration binds a value and calls its [Symbol.dispose]() method when the enclosing block exits, whether that exit is a return, a throw or a break. It does what try/finally did, without the nesting. Node 24 runs the syntax natively, and TypeScript has compiled it since 5.2.
Explicit Resource Management reached Stage 4 at the May 2026 TC39 meeting and now sits on the finished proposals list for the 2027 edition of the language. It is settled.
What does the using keyword do in TypeScript?
Declare with using instead of const, and give the object a [Symbol.dispose]() method. TypeScript ships Disposable and AsyncDisposable interfaces for the contract.
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
class TempDir implements Disposable {
readonly path = mkdtempSync(join(tmpdir(), 'fixture-'));
[Symbol.dispose](): void {
rmSync(this.path, { recursive: true, force: true });
}
}
export function buildFixture(): void {
using dir = new TempDir();
writeSeedData(dir.path);
// rmSync runs here, and also if writeSeedData throws
}
Two using declarations in one block dispose in reverse order. Last opened, first closed, which is what you want when the second resource was built on top of the first.
The type checker holds you to the contract. Point using at something with no dispose method and you get TS2850: The initializer of a 'using' declaration must be either an object with a '[Symbol.dispose]()' method, or be 'null' or 'undefined'. Those last two are deliberate, so an optional resource does not need a branch around it.
Which resources need await using?
Anything whose cleanup returns a promise. Those objects implement [Symbol.asyncDispose]() and need await using, which awaits the disposal while the scope unwinds. The await describes the exit, not the declaration.
Node has been fitting this to its built-ins since v20. FileHandle from fs.promises got [Symbol.asyncDispose]() in v20.4.0. HTTP, HTTPS and net servers picked it up across v20.4.0 and v20.5.0, fs.Dir in v24.1.0, and Worker from node:worker_threads in v24.2.0. Most of them stopped being marked experimental in v24.2.0. On the synchronous side there is readline (v23.10.0), DatabaseSync from node:sqlite (v23.11.0), and the Timeout and Immediate handles returned by setTimeout and setImmediate.
Test fixtures are where the payoff shows up first:
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import test from 'node:test';
test('serves the health check', async () => {
await using server = createServer((_req, res) => res.end('ok'));
server.listen(0);
await new Promise((resolve) => server.once('listening', resolve));
const { port } = server.address() as { port: number };
const res = await fetch(`http://127.0.0.1:${port}/health`);
assert.equal(await res.text(), 'ok');
});
No after hook, and no leaked handle holding the built-in test runner open when the assertion fails.
Read the disposal semantics before you trust any of them. subprocess[Symbol.dispose]() calls subprocess.kill() with SIGTERM and returns immediately. It does not wait for the child to exit, so a process that ignores SIGTERM is still there after the block ends.
How do you wrap a library with no dispose method?
Most of npm predates all of this. DisposableStack and AsyncDisposableStack are the adapters. adopt(value, onDispose) pairs a foreign object with a cleanup callback, defer(onDispose) registers a bare callback, and use(value) takes something already disposable. The stack is itself disposable, so you drive it with using.
import { Pool } from 'pg';
const pool = new Pool();
export async function ordersForCustomer(id: string) {
await using stack = new AsyncDisposableStack();
const client = stack.adopt(await pool.connect(), (c) => c.release());
const { rows } = await client.query(
'SELECT * FROM orders WHERE customer_id = $1',
[id],
);
return rows;
}
client.release() now runs on every path out of that function. Forgetting it is the standard way to exhaust a pool, and pools are unforgiving under serverless connection limits.
move() handles the harder case: a factory that opens three things and hands ownership to its caller. Build them into a local stack, and once every step has succeeded, call stack.move() to transfer the whole set into a new stack the caller owns. If a step throws before the move, the local stack disposes whatever it managed to open.
What happens when both the body and the disposal throw?
You get a SuppressedError, which is also new. Its error property holds the disposal failure and suppressed holds the error that was already travelling:
try {
doWork();
} catch (e) {
if (e instanceof SuppressedError) {
console.error(e.error); // thrown by [Symbol.dispose]()
console.error(e.suppressed); // the original failure
}
}
The message reads An error was suppressed during disposal. Log both properties. A dispose method that throws while unwinding a real failure will otherwise bury the bug you were chasing.
Which runtimes and tsconfig settings do you need?
Node 24 is the floor for the native syntax and globals. MDN’s compatibility data puts DisposableStack at Node 24.0.0 and Deno 2.2.10, with Chrome 134 and Firefox 141. WebKit added the full proposal in Safari Technology Preview 250 on 13 August 2026, so as of September 2026 every engine has an implementation but stable Safari does not have the syntax yet.
For TypeScript, add the lib and leave the target alone:
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable"],
"module": "nodenext"
}
}
There is no numbered lib for it, because the feature lands in the 2027 edition. Below an ES2022 target, TypeScript rewrites the syntax into __addDisposableResource and __disposeResources helpers that look the symbols up at runtime, so an old runtime without them throws TypeError: Symbol.dispose is not defined. The 5.2 release notes give a two-line polyfill that covers you if you never touch the stack classes:
Symbol.dispose ??= Symbol('Symbol.dispose');
Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose');
esbuild lowers both forms as well, so bundled output is fine.
Where using bites back
Three edges are worth knowing before you commit.
A using declaration at the top level of an ES module is legal, and it disposes the moment the module body finishes evaluating, not when the process exits. A server opened that way is closed before it serves anything.
A bare using inside a switch case is now a syntax error. It used to work, then the scoping proved too confusing and it was cut from the spec; esbuild followed the removal in 2025. Wrap the case body in braces and it is fine again.
Disposal is tied to the block, not to the object’s lifetime. Return a using variable and your caller receives something already disposed. When ownership escapes the scope, reach for move() or an explicit close instead.
What I would actually do
Use it for anything with a paired open and close: file handles, temp directories, database connections, spawned processes, test servers, the DatabaseSync instances in an embedded SQLite layer. Those are the places where a missed finally surfaces as a leaked handle three weeks later. Add esnext.disposable to lib, put [Symbol.dispose]() on your own resource classes, and use AsyncDisposableStack.adopt for the libraries that have not caught up.
I would not rewrite working try/finally code to match, and I would not ship the syntax to browsers until stable Safari has it. It is also not a memory tool. Nothing here runs at garbage collection time, and a resource nobody disposes is exactly as leaked as it was before.
Whoooop builds and maintains TypeScript services and APIs, which is usually where this sort of resource handling either works quietly or costs someone a Friday evening. If you want a second pair of eyes on a Node codebase, our TypeScript development work is the place to start.