`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:

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:

  1. Cleanup skipped on non-throw exits. return, break, continue, and yield in a generator all leave a block. try/finally covers them — but only if the acquisition sits inside the try, and only if someone remembers. Every connection-pool exhaustion incident is this bug.
  2. The callback-wrapper tax. The old workaround, withConnection(async (c) => { ... }), colors your code: you can't return from the outer function, await chains 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.
  3. Errors eating errors. In try { throw A } finally { throw B }, error A is destroyed. using defines a SuppressedError chain instead, so a failing close() 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:

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:

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.

  1. 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 — existing try/finally callers keep working.
  2. Grep for the leak-prone shapes: rg 'finally' and check that each acquisition is inside its try; rg 'with[A-Z].*async \(' for callback wrappers; rg '\.release\(|\.close\(|\.destroy\(' and see which call sites have an early return above them.
  3. Convert callback wrappers by keeping both. Have withConnection internally use using, then add a getConnection() that returns a disposable. Delete the wrapper once call sites migrate.
  4. Watch scope widening. try { const c = open(); ... } finally { c.close() } released at the try's end; a naive rewrite to using c = open() at function top holds the resource until the function ends. If that matters (hot connection pools), keep the explicit { } block.
  5. Don't mix using with manual close(). Double-disposal is your problem, not the engine's — make disposers idempotent.
  6. Enable the compiler support explicitly: TypeScript needs target: es2022+ with lib including esnext.disposable (or esnext), and useDefineForClassFields semantics 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)).