TypeScript using Keyword: Explicit Resource Management

Photo by Pixabay on Pexels
The using keyword declares a resource whose Symbol.dispose method is called automatically when the enclosing block exits, whether by normal completion, a return, or a thrown error. It replaces manual try/finally cleanup so files, database connections, and locks are released deterministically without you writing release code.
using calls the synchronous Symbol.dispose method at the end of scope, while await using calls the asynchronous Symbol.asyncDispose method and awaits the promise it returns before continuing. Use await using for resources whose cleanup is asynchronous, such as closing a database connection or flushing a stream.
Explicit resource management with using and await using shipped in TypeScript 5.2. It implements the TC39 explicit resource management proposal, which was at Stage 3 when the feature was released.
You must set the compilation target to es2022 or below, and your lib must include either esnext or the narrower esnext.disposable so the Disposable and AsyncDisposable types resolve. Note that esnext.disposable only adds type declarations, not a runtime polyfill.
Implement the Disposable interface by adding a synchronous Symbol.dispose method, or the AsyncDisposable interface by adding an async Symbol.asyncDispose method. Put your cleanup logic inside that method, then declare instances with using or await using and the runtime calls it at scope exit.

Photo by Pixabay on Pexels
Key Takeaway
The TypeScript using keyword, shipped in TypeScript 5.2, automatically disposes a resource when its scope ends by calling its Symbol.dispose method, while await using calls Symbol.asyncDispose and awaits it. This replaces error-prone try/finally cleanup, so database connections, file handles, and locks close deterministically even when code throws.
Every backend engineer has shipped the same bug at least once: a database connection, a file handle, or a lock that was opened but never released because a cleanup line was forgotten, or because an early return skipped the finally block. The resource leaks quietly, the connection pool starves under load, and the incident lands hours later when the symptom is far from the cause.
TypeScript 5.2 borrowed a fix for this from the TC39 explicit resource management proposal: the using and await using declarations. They tie a resource's lifetime to the block it lives in, so cleanup is guaranteed by the language rather than by your discipline. In this post I walk through the problem, the syntax, how to make your own classes disposable, and the exact tsconfig settings the feature needs.
The classic pattern for deterministic cleanup is try/finally. You acquire a resource, do your work in the try block, and release it in finally so that the release runs whether the work succeeds or throws. It works, but it puts the burden entirely on the author: every acquisition needs a matching finally, and every early exit path has to run through it.
Here is the shape of it with a Postgres connection. The finally block is doing the real safety work, and it is exactly the line most likely to be dropped during a refactor or an added early return.
async function loadUsers() {
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
const { rows } = await client.query("SELECT id, email FROM users LIMIT 10");
return rows;
} finally {
// Forget this line and the connection leaks until the pool starves.
await client.end();
}
}A using declaration binds a resource to the current block. When control leaves that block for any reason, whether a normal completion, a return, or a thrown error, the runtime calls the resource's Symbol.dispose method automatically. There is no finally to write and no exit path to miss. For asynchronous cleanup you write await using instead, which looks up Symbol.asyncDispose and awaits the returned promise before continuing.
Disposal follows last-in-first-out order, exactly like a stack: if you declare two resources in a block, the one declared second is disposed first. That matches how nested resources usually depend on each other, and it is the same ordering nested try/finally blocks would give you, without the nesting.
using and await using are block-scoped declarations. They work inside functions, loops, and if blocks, but they cannot appear at the top level of a classic script. Reach for a DisposableStack or the defer method when you need to register cleanup for a resource that does not itself implement the disposable interface.
A resource opts in by implementing one of two interfaces. Disposable requires a synchronous Symbol.dispose method; AsyncDisposable requires an async Symbol.asyncDispose method. Anything that closes over I/O, a connection, a file, a stream, or a lock, is a natural candidate. Here is a Postgres connection wrapper that closes itself through await using.
import { Client } from "pg";
// A resource opts in by implementing AsyncDisposable:
// it must define an async [Symbol.asyncDispose]() method.
class ManagedConnection implements AsyncDisposable {
private constructor(private readonly client: Client) {}
static async connect(url: string): Promise<ManagedConnection> {
const client = new Client({ connectionString: url });
await client.connect();
return new ManagedConnection(client);
}
query(text: string, params?: unknown[]) {
return this.client.query(text, params);
}
async [Symbol.asyncDispose](): Promise<void> {
await this.client.end();
}
}
async function loadUsers() {
// "await using" awaits [Symbol.asyncDispose] when the block exits.
await using db = await ManagedConnection.connect(process.env.DATABASE_URL!);
const { rows } = await db.query("SELECT id, email FROM users LIMIT 10");
return rows;
// db.client.end() runs here automatically -- even if the query throws.
}Notice what the caller no longer has to do. There is no finally, no manual end call, and no way to forget the cleanup, because the moment the block exits, the runtime awaits Symbol.asyncDispose for you. If the query throws, disposal still runs; if two disposals both throw, the runtime raises a SuppressedError that keeps a reference to both so nothing is silently swallowed.
The feature landed in TypeScript 5.2. To compile it you must set your target to es2022 or below, and your lib must include either esnext or the narrower esnext.disposable so the Disposable and AsyncDisposable types resolve. A minimal configuration looks like this.
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable", "dom"]
}
}Because most runtimes did not support the well-known symbols when the feature shipped, TypeScript downlevels using into equivalent try/finally logic and expects polyfills for Symbol.dispose, Symbol.asyncDispose, DisposableStack, AsyncDisposableStack, and SuppressedError. The engine side has since caught up: V8 shipped explicit resource management in v13.8 and Chromium 134, and modern JavaScript runtimes now support the syntax natively.
esnext.disposable only adds the type declarations, it does not add a runtime polyfill. If your deployment target lacks native support, you still need the Symbol.dispose and Symbol.asyncDispose polyfills, or a helper library that provides them, or the using blocks will throw at runtime even though the build passed.
The two are not rivals so much as one replacing the boilerplate of the other. try/finally is a manual mechanism you assemble correctly every time; using is the same guarantee moved into the declaration itself. The table sums up where the difference actually bites.
| Concern | try / finally | using / await using |
|---|---|---|
| Cleanup trigger | You call release manually in finally | Runtime calls Symbol.dispose at scope end |
| Forgetting cleanup | Silent leak, the compiler stays quiet | Impossible once the resource is declared with using |
| Multiple resources | Nested blocks or careful manual ordering | One line each, disposed in last-in-first-out order |
| Async cleanup | await inside finally, easy to omit | await using awaits Symbol.asyncDispose for you |
| Early return or throw | Every exit path must route through finally | Disposal happens on every exit path automatically |
My rule of thumb is simple: if a value owns something that must be released, give it a Symbol.dispose or Symbol.asyncDispose and hand it out with using. You stop writing cleanup code and start declaring ownership, and the whole class of forgotten-finally leaks disappears from your codebase.