`using` Declarations: JavaScript Finally Got RAII
TypeScript · Intermediate · 6 min read · published
What this solves: Cleanup code that lives in `finally` blocks gets skipped on early returns, swallows the original error, and nests three levels deep when you have two resources. `using` and `await using` attach disposal to the variable itself so it always runs, in reverse order, without a pyramid.
What Changed
JavaScript now has deterministic, scope-bound resource cleanup. The Explicit Resource Management proposal adds two new declaration forms — using and await using — plus two well-known symbols, Symbol.dispose and Symbol.asyncDispose, and two helper classes, DisposableStack and AsyncDisposableStack.
Where you can use it today:
- TypeScript has supported the syntax since 5.2, downleveling it for older targets (you need
libentries for the symbols, or a polyfill, when targeting older runtimes). - Node.js exposes
Symbol.dispose/Symbol.asyncDisposeand has made core objects disposable —FileHandle,AbortController, timers,Worker, test-runner mocks — and recent V8-based releases support theusingsyntax natively. - Browsers shipped it in recent Chromium; Firefox and Safari have been behind.
The pattern is C#'s using / Python's with / C++'s RAII, finally available in JS without a callback wrapper.
The Old Way vs The New Way
// Before: nesting, and one forgotten finally = a leak
async function copyReport(src: string, dst: string) {
const input = await fs.open(src, 'r');
try {
const output = await fs.open(dst, 'w');
try {
const lock = await acquireLock('reports');
try {
const header = await input.read(/* ... */);
if (!header.bytesRead) return; // easy to add this ABOVE a try
await output.write(header.buffer);
} finally {
await lock.release();
}
} finally {
await output.close();
}
} finally {
await input.close();
}
}
// After: flat, and the early return is safe
async function copyReport(src: string, dst: string) {
await using input = await fs.open(src, 'r');
await using output = await fs.open(dst, 'w');
await using lock = await acquireLock('reports'); // needs [Symbol.asyncDispose]
const header = await input.read(/* ... */);
if (!header.bytesRead) return; // lock, output, input all released, in that order
await output.write(header.buffer);
}
Making your own type participate is three lines:
class Span {
constructor(private name: string, private t0 = performance.now()) {}
[Symbol.dispose]() { tracer.record(this.name, performance.now() - this.t0); }
}
function handle(req: Request) {
using _span = new Span('handle'); // recorded even if this throws
return route(req);
}
Why It Was Added
Three specific bug classes:
- Cleanup skipped on non-throw exits.
return,break,continue, andyieldin a generator all leave a block.try/finallycovers them — but only if the acquisition sits inside thetry, and only if someone remembers. Every connection-pool exhaustion incident is this bug. - The callback-wrapper tax. The old workaround,
withConnection(async (c) => { ... }), colors your code: you can'treturnfrom the outer function,awaitchains get awkward, and composing three resources means three levels of closure. The higher-order function also has to guess whether it's sync or async. - Errors eating errors. In
try { throw A } finally { throw B }, error A is destroyed.usingdefines aSuppressedErrorchain instead, so a failingclose()never hides the real failure.
It also gives generators and iterators a home for cleanup that runs when the consumer abandons them early.
How It Works Underneath
A block containing using declarations gets an implicit disposal list. At each using declaration the engine evaluates the initializer, looks up [Symbol.dispose] (or [Symbol.asyncDispose], falling back to [Symbol.dispose], for await using), throws a TypeError immediately if the value is neither disposable nor null/undefined, and pushes the method onto the list. When the block completes — normally or abruptly — the list is drained LIFO.
flowchart TD
A["Enter block"] --> B["using a = openA()"]
B --> C["push a[Symbol.dispose]"]
C --> D["using b = openB()"]
D --> E["push b[Symbol.dispose]"]
E --> F{"Block completes"}
F -->|normal return| G["Drain stack LIFO"]
F -->|throw / break| H["Record pending error"]
H --> G
G --> I["call b.dispose()"]
I --> J{"disposer threw?"}
J -->|no| K["call a.dispose()"]
J -->|yes| L["pending = new SuppressedError(new, pending)"]
L --> K
K --> M{"pending error?"}
M -->|yes| N["rethrow to caller"]
M -->|no| O["exit cleanly"]
Key consequences of that design:
- LIFO is what makes dependent resources correct: the lock acquired last is released first.
- Disposal happens at block exit, not function exit — wrap in
{ ... }to narrow a lifetime deliberately. - The method is captured at declaration time, so reassigning the variable later doesn't change what gets disposed (
usingbindings are const anyway). nullandundefinedare legal, which makes conditional acquisition (using x = flag ? open() : null) work without branching.await usinginserts an implicitawaitat scope exit — that's a real suspension point, so it can only appear in async contexts.
When you need a dynamic number of resources, use DisposableStack:
using stack = new DisposableStack();
for (const path of paths) {
stack.use(openSync(path)); // all closed at block exit, reverse order
}
stack.defer(() => metrics.flush()); // arbitrary callback, no object needed
const owned = stack.move(); // hand ownership to the caller instead
move() is the escape hatch for factory functions: build several resources, and if construction succeeds, transfer them out so the local stack disposes nothing.
Should You Adopt It Yet
In TypeScript on Node: yes, for resource-owning code paths — DB connections, file handles, locks, temp directories, test fixtures, spans. The downlevel emit is a small helper and has been stable for a long time.
Costs and caveats to price in:
- Tooling lag. Older ESLint parsers, Babel configs, Jest transformers, or bundlers may choke on the syntax. Check your whole pipeline on one file before a wide rollout.
- Runtime floor. If you downlevel, you need
Symbol.disposeto exist at runtime; Node defines it in recent versions, otherwise polyfill it (Symbol.dispose ??= Symbol('Symbol.dispose')) before anything else loads. Mismatched polyfills across packages produce two different symbols and silently non-disposable objects. - Browser support is uneven. For shipped frontend code, treat it as a compile-target feature, not a native one.
- Not for shared/long-lived objects. A process-wide DB pool or HTTP agent doesn't belong in a
using— scope-bound disposal is exactly wrong there.
Wait if you're on plain JavaScript with an older toolchain and no transform step, or if your team's mental model of finally is already working fine in a small codebase.
Migration Notes
Do it leaf-first, not top-down.
- Make your own wrappers disposable before changing call sites. Add
[Symbol.dispose]()to the connection/lock/temp-dir classes you already own. This is backward-compatible — existingtry/finallycallers keep working. - Grep for the leak-prone shapes:
rg 'finally'and check that each acquisition is inside itstry;rg 'with[A-Z].*async \('for callback wrappers;rg '\.release\(|\.close\(|\.destroy\('and see which call sites have an earlyreturnabove them. - Convert callback wrappers by keeping both. Have
withConnectioninternally useusing, then add agetConnection()that returns a disposable. Delete the wrapper once call sites migrate. - Watch scope widening.
try { const c = open(); ... } finally { c.close() }released at thetry's end; a naive rewrite tousing c = open()at function top holds the resource until the function ends. If that matters (hot connection pools), keep the explicit{ }block. - Don't mix
usingwith manualclose(). Double-disposal is your problem, not the engine's — make disposers idempotent. - Enable the compiler support explicitly: TypeScript needs
target: es2022+ withlibincludingesnext.disposable(oresnext), anduseDefineForClassFieldssemantics unaffected. Ban the pattern in ESLint where it shouldn't appear (module-scope singletons) rather than trusting review.
Key takeaway: If a value owns something that must be released, give it `[Symbol.dispose]` and declare it with `using` — the block scope becomes the lifetime, and no early return can leak it.
Real-world challenge
Your integration test suite passes locally but the CI job dies after ~120 tests with `remaining connection slots are reserved for non-replication superuser connections`. The helper each test uses does `const conn = await pool.connect()` and releases it in a `finally`. Someone added a `expect.soft` early-return guard and a couple of `if (!row) return;` short-circuits inside those helpers last week.
Diagnose. Connection count grows monotonically, so a path exists where release() never runs. Check pool.totalCount vs pool.idleCount in an afterEach — if totalCount climbs test over test, it's a leak, not slow reclamation.
The usual cause: the finally wraps only part of the helper, and the new early returns sit above the try, or a nested helper acquires a second connection that has no cleanup at all. grep -n 'pool.connect' test/ and confirm every acquisition is immediately followed by a try.
Fix. Make the connection self-disposing so lifetime is tied to scope, not to disciplined try/finally placement:
async function conn() {
const c = await pool.connect();
return Object.assign(c, {
[Symbol.dispose]() { c.release(); }
});
}
test('orders', async () => {
using db = await conn(); // released on ANY exit path
const row = await db.query(...);
if (!row.rowCount) return; // no longer leaks
expect(row.rows[0].total).toBe(42);
});
Then add a guard so regressions fail loudly: afterAll(() => expect(pool.totalCount).toBeLessThan(5)).