DEV Community

Cover image for TypeScript's using Keyword: Explicit Resource Management for Real
Gabriel Anhaia
Gabriel Anhaia

Posted on

TypeScript's using Keyword: Explicit Resource Management for Real


You've seen this. A junior on the team writes a function that
opens a database transaction, runs a few statements, and commits
at the end. One of the middle statements throws. The commit never
runs. Neither does the rollback, because the
try { ... } catch { rollback() } block got skipped during the
refactor that pulled the rollback into a helper that is now
unreachable. The connection sits open, holding a row lock. The
next request on that table times out. The alert count on the
dashboard turns red.

Every long-lived TypeScript codebase has a version of this. A file
handle that did not close. A redis lease that did not release.
A tracing span that did not end, so a downstream span shows up as
the root of its own trace and the latency graph splits in two.
The fix every senior reviewer writes in the PR comment is the
same: wrap it in try/finally and put the cleanup in the
finally.

That review comment now has a real language feature behind it.

What using actually does

The using declaration is the JavaScript answer to C#'s using,
Python's with, and Java's try-with-resources. You bind a value
with using instead of const, and at the end of the enclosing
block the runtime calls [Symbol.dispose]() on that value. There
is no finally to forget. There is no nesting that drifts when
the function gets refactored. The cleanup is part of the binding.

The asynchronous version, await using, calls
[Symbol.asyncDispose]() and awaits it. Both forms run the
cleanup whether the block returns normally, throws, or is exited
by break/return. If the block throws and the disposer
throws, the runtime wraps the second error in a SuppressedError
so neither one is silently lost.

The shape of a disposable type is a single method:

class Lock implements Disposable {
  constructor(private readonly id: string) {}

  [Symbol.dispose](): void {
    releaseLock(this.id);
  }
}

function acquire(name: string): Lock {
  const id = takeLock(name);
  return new Lock(id);
}

{
  using lock = acquire("invoices");
  doWork();
}
// releaseLock("invoices") has run by here, even if doWork threw.
Enter fullscreen mode Exit fullscreen mode

Disposable and AsyncDisposable are global TypeScript types
introduced in TS 5.2. They describe any object carrying the
respective symbol method. You do not need to import them.

Where it runs today

The TC39 explicit resource management proposal
is at Stage 3 with conditional advancement to Stage 4 granted
in 2025

pending test262 sign-off. In practice that means runtimes have
been shipping it ahead of the final spec landing, and the syntax
and protocol are stable enough to write production code against.

The runtime picture as of April 2026 (verify against your point
release before relying on any single line below — see MDN's
compatibility table

for the live status):

  • Node.js — current LTS lines ship using/await using natively via the bundled V8. Symbol.dispose and Symbol.asyncDispose are out of the experimental gate, and built-in disposability on Node's own APIs (file handles, network listeners) is rolling out incrementally. Check the Node release notes for your line.
  • Bun — supported in current Bun 1.x; check the Bun changelog for the specific APIs on your version (bun:test mocks and scheduling helpers have been adopting the protocol over time).
  • Deno — supports the syntax, with caveats around how its TypeScript transpilation pipeline handles using on .ts files versus .js files. Verify against your Deno version before relying on the native path.
  • Browsers — Chromium-based browsers picked it up first via V8; Firefox followed; Safari is the slowest mover. Check caniuse or MDN BCD for the exact versions you need to support.

For TypeScript itself, using landed in TypeScript 5.2,
released in August 2023. To compile down for older targets you
set target to es2022 or below and add "esnext.disposable"
(or "esnext") to lib. The compiler emits a runtime helper
called __disposeResources that walks a stack of registered
disposers in reverse order. Measure the helper's bundle impact
against your own size budget before shipping to a constrained
browser target.

Pattern 1: a transaction handle that auto-commits or rolls back

The opening scenario is the textbook case. A transaction object
that owns its own commit-or-rollback logic, exposed as an
AsyncDisposable, removes the entire class of "forgot the
rollback" bug.

import type { Pool, PoolClient } from "pg";

class Tx implements AsyncDisposable {
  private settled = false;

  constructor(private readonly client: PoolClient) {}

  async query(sql: string, params?: unknown[]): Promise<void> {
    await this.client.query(sql, params);
  }

  async commit(): Promise<void> {
    await this.client.query("COMMIT");
    this.settled = true;
  }

  async [Symbol.asyncDispose](): Promise<void> {
    try {
      if (!this.settled) {
        await this.client.query("ROLLBACK");
      }
    } finally {
      this.client.release();
    }
  }
}

async function begin(pool: Pool): Promise<Tx> {
  const client = await pool.connect();
  await client.query("BEGIN");
  return new Tx(client);
}
Enter fullscreen mode Exit fullscreen mode

The call site is now a single block. Commit on the happy path,
let dispose roll back on every other path.

async function transferFunds(
  pool: Pool,
  fromId: string,
  toId: string,
  cents: number,
): Promise<void> {
  await using tx = await begin(pool);

  await tx.query(
    "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
    [cents, fromId],
  );

  await tx.query(
    "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
    [cents, toId],
  );

  await tx.commit();
}
Enter fullscreen mode Exit fullscreen mode

If the second UPDATE throws because of a check constraint, the
function exits, dispose runs, the rollback happens, the client
returns to the pool. There is no try/catch/finally ladder for
the reviewer to scan. The control flow is the cleanup.

The settled flag is the part that earns its keep. A double-call
is the failure mode you want to avoid: dispose calling rollback
on a transaction that already committed. Tracking the boolean is
trivial; the disposer becomes idempotent.

Pattern 2: a file or socket that auto-closes

On current Node, fs.promises.open returns a FileHandle that
implements Symbol.asyncDispose. The object has been disposable
since the experimental flag period, which means migrating an
older codebase is one keyword:

import { open } from "node:fs/promises";

async function readFirstLine(path: string): Promise<string> {
  await using handle = await open(path, "r");
  const buf = Buffer.alloc(4096);
  const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
  const data = buf.subarray(0, bytesRead).toString("utf8");
  return data.split("\n", 1)[0];
}
Enter fullscreen mode Exit fullscreen mode

The await handle.close() call you used to see at the bottom of
this function is gone. Throw inside read, return early on a
short file, exit through any path. The handle closes. For older
Node versions and for sockets that do not yet ship a built-in
disposer, wrap them yourself:

import { Socket } from "node:net";

class ManagedSocket implements AsyncDisposable {
  constructor(private readonly socket: Socket) {}

  async [Symbol.asyncDispose](): Promise<void> {
    if (this.socket.destroyed) return;
    await new Promise<void>((resolve) => {
      this.socket.end(() => resolve());
    });
    this.socket.destroy();
  }
}
Enter fullscreen mode Exit fullscreen mode

Same call shape as the transaction. The wrapper is six lines.
The bug it kills is the kind of leak that shows up only under
sustained load: sockets that linger past the request that opened
them, eating file descriptors.

Pattern 3: a trace span that auto-ends

The third case is observability. OpenTelemetry's tracer.startActiveSpan
takes a callback so the SDK can call span.end() for you. The
callback shape is a workaround for a missing language feature.
With using, you can write spans the same way you write
transactions: bind it, work, exit.

import { trace, type Span } from "@opentelemetry/api";

class TracedSpan implements Disposable {
  constructor(private readonly span: Span) {}

  setAttribute(k: string, v: string | number | boolean): void {
    this.span.setAttribute(k, v);
  }

  recordError(e: unknown): void {
    if (e instanceof Error) this.span.recordException(e);
  }

  [Symbol.dispose](): void {
    this.span.end();
  }
}

function startSpan(name: string): TracedSpan {
  const span = trace.getTracer("app").startSpan(name);
  return new TracedSpan(span);
}

function processOrder(orderId: string): void {
  using span = startSpan("order.process");
  span.setAttribute("order.id", orderId);
  chargeCard(orderId);
}
Enter fullscreen mode Exit fullscreen mode

The block boundary is the span boundary. Nest them and you get
nested spans. Throw inside, the span still ends, and any error
is on the call stack rather than swallowed by a forgotten
finally. The recordError helper plus a thin try/catch around
the body covers the case where you want the exception attached
to the span without changing the disposal contract.

When a single disposer is not enough

Sometimes you collect resources in a loop and cannot bind each
one to a single name. DisposableStack and AsyncDisposableStack
are the runtime answer:

async function fanOut(urls: string[]): Promise<Response[]> {
  await using stack = new AsyncDisposableStack();
  const ctrls = urls.map(() => {
    const c = new AbortController();
    stack.defer(() => c.abort());
    return c;
  });
  return await Promise.all(
    urls.map((u, i) => fetch(u, { signal: ctrls[i].signal })),
  );
}
Enter fullscreen mode Exit fullscreen mode

Every controller registered through defer runs in reverse on
exit. If any fetch throws, the rest are aborted. The stack is
the same primitive a using declaration uses internally, exposed
when you need it directly.

What stays surprising

using only fires at block exit. Returning a disposable up the
call stack defeats the point. The binding goes out of scope at
the return statement, and the disposer runs before the caller
sees the value. If you want the caller to own the lifetime,
return the underlying resource and let the caller bind it with
their own using.

Errors thrown inside the disposer are real. A buggy
[Symbol.dispose] that throws an unrelated error masks the
original failure unless the runtime promotes it to a
SuppressedError. Keep disposers boring: they release locks,
end spans, close handles, and they do not do business logic.

Downlevel-compiled using is not free. Each disposable goes
through the __disposeResources helper, which maintains a linked
structure per scope. For a hot loop that allocates thousands of
disposables per iteration, the helper shows up in a flame graph.
On a modern target where the runtime implements using natively,
the cost disappears. The fix in the rare case where it matters
is to hoist the disposable out of the loop, not to abandon
using.

A small bet that pays off

The next time you write try { ... } finally { handle.close() },
or you find a stale rollback in a postmortem, or your trace graph
splits because a span did not end, ask whether the resource
itself should own the cleanup. Add a [Symbol.dispose]. Bind it
with using. The control flow is now the contract.


If this was useful

using is one of the post-5.2 features TypeScript Essentials
walks through end to end alongside the day-to-day machinery:
narrowing, modules, async, and the runtime fences across Node,
Bun, Deno, and the browser. If the three patterns above mapped onto
something you maintain, that is the book to put on the desk
beside it. TypeScript in Production picks up where this post
ends with the build, target, and lib decisions that make a
feature like using portable across runtimes.

If you are coming from JVM languages where try-with-resources is
already muscle memory, Kotlin and Java to TypeScript makes the
bridge into TypeScript's structural model. If you are coming
from PHP 8+, PHP to TypeScript covers the same ground from the
other side. The TypeScript Type System is the deep dive on the
generics, mapped types, and infer patterns that compose with
disposable types into library-grade APIs.

The five-book set:

  • TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
  • The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
  • Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
  • PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
  • TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)