EduFlow

Section 5 · Shipping TypeScript

using Declarations and Standard Decorators

17 min4 min readCode playground

Lesson at a glance

Release resources reliably with using declarations and Symbol.dispose, and write typed standard decorators for cross-cutting behaviour such as logging and retries.

  1. 1using: cleanup tied to scope
  2. 2Standard decorators

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Cartwheel’s checkout reserves stock before charging a card, then releases the reservation if anything fails. The first version of that code looked like this:

TypeScript
function placeOrder(skus: string[]) {
  const reservations = skus.map((sku) => reserve(sku));
  chargeCard(); // throws on a declined card
  reservations.forEach((r) => r.release());
}

A declined card skips the last line, and the stock stays locked until a nightly job notices. The traditional fix is try/finally, nested once per resource. This lesson covers the language’s built-in answer, and then a second modern feature that keeps cross-cutting code like retries out of your business logic: standard decorators.

using: cleanup tied to scope

A using declaration is like const, with one addition: when the block it lives in exits, by return, by falling off the end, or by throw, JavaScript calls the value’s [Symbol.dispose]() method.

TypeScript
function reserve(sku: string): Disposable {
  log(`reserve ${sku}`);
  return { [Symbol.dispose]: () => log(`release ${sku}`) };
}

function placeOrder() {
  using tent = reserve('TENT-2P');
  using mug = reserve('MUG-SET');
  chargeCard(); // even if this throws…
}               // …both are released here: MUG-SET first, then TENT-2P

Several using declarations are disposed in reverse order, exactly like nested finally blocks, so a resource is always released before the ones it was created after. If disposal itself throws while another error is in flight, both are kept in a SuppressedError rather than one silently replacing the other.

For cleanup that is asynchronous, such as closing a database transaction, use await using with an object that has [Symbol.asyncDispose]() returning a promise. And when the number of resources is dynamic, collect them in a DisposableStack (or AsyncDisposableStack), which is itself disposable:

TypeScript
function placeOrder(skus: string[]) {
  using stack = new DisposableStack();
  for (const sku of skus) stack.use(reserve(sku));
  chargeCard();
} // every reservation released, in reverse order

using arrived in TypeScript 5.2. It runs natively in Node 24, Chrome and Edge 134+ and Firefox 141+, but Safari does not support it yet (as of late 2026), so it is not Baseline. For code that ships to browsers, let TypeScript or your bundler downlevel the syntax, and make sure the runtime has Symbol.dispose (a polyfill provides it where it is missing). The types live in the esnext.disposable lib (included in "lib": ["esnext"]). The exercise playground uses the ES2022 lib, so its starter declares those two pieces itself, which is a preview of the declaration merging you will learn in the next lesson.

Making your own APIs disposable

using pays off most when your own APIs return disposables. The emitter from Type-Safe Event Emitters and Builders returned an off function; returning an object with [Symbol.dispose] as well lets callers scope a subscription to a block:

TypeScript
function subscribe<K extends keyof CheckoutEvents>(event: K, handler: (p: CheckoutEvents[K]) => void): Disposable {
  const off = bus.on(event, handler);
  return { [Symbol.dispose]: off };
}

async function waitForShipment(orderId: number) {
  using _ = subscribe('order.shipped', (e) => { if (e.orderId === orderId) notify(e); });
  await pollUntilShipped(orderId);
} // listener removed here, however the function ends

Good candidates are anything with a matching “undo”: locks and reservations, event subscriptions, timers, temporary files, database transactions (with await using). If you find yourself writing try/finally only to call a cleanup method, that resource wants a [Symbol.dispose].

Standard decorators

A decorator is a function applied with @ to a class or class member. TypeScript 5.0 implemented the standard (TC39) decorators, and TypeScript compiles them down to ordinary function calls, so they run in any runtime. A method decorator receives the original method and a context object, and may return a replacement:

TypeScript
function logged<This, Args extends unknown[], Return>(
  method: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`→ ${name}`, args);
    return method.call(this, ...args);
  };
}

class PaymentGateway {
  @logged
  charge(amountCents: number, currency: string) { /* … */ }
}

The generics make the decorator work on any method without any: This, Args and Return are inferred from the method it decorates, and the replacement keeps the same signature. Decorators that take options are decorator factories, functions that return the decorator:

TypeScript
function retry(times: number) {
  return function <This, Args extends unknown[], Return>(
    method: (this: This, ...args: Args) => Return,
    _context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
  ) {
    return function (this: This, ...args: Args): Return {
      for (let attempt = 1; ; attempt++) {
        try {
          return method.call(this, ...args);
        } catch (e) {
          if (attempt >= times) throw e;
        }
      }
    };
  };
}

class PaymentGateway {
  @retry(3)
  charge(amountCents: number, currency: string) { /* … */ }
}

Stacked decorators apply from the bottom up. With @logged above @retry(3), the retry wrapper is applied to charge first and logged wraps the result, so you get one log line per call rather than one per attempt. Swap them and you log every attempt. Neither is wrong, but the order is behaviour, so choose it on purpose.

context also offers context.name, context.static, context.private and context.addInitializer(fn), which runs code when an instance is created (the usual way to write a @bound decorator that binds a method to its instance).

In the exercise you will release stock reservations with using and add a typed @retry to Cartwheel’s payment gateway. Next lesson: describing code you did not write, with declaration files.

Key takeaways

  • A using declaration calls the value’s [Symbol.dispose]() when the enclosing block exits, whether it returns normally or throws.
  • Several using declarations in one block are disposed in reverse order, like nested try/finally blocks.
  • await using and Symbol.asyncDispose do the same for cleanup that returns a promise.
  • Standard decorators (TypeScript 5.0+) are functions of (value, context) that can return a replacement; they are not the same as the older experimentalDecorators.
  • Type a method decorator with ClassMethodDecoratorContext and generic This, Args and Return parameters so it works on any method without any.

Further reading

Check your understanding

4 questions

Question 1 of 4

What does this log?

TypeScript
function checkout() {
  using a = reserve('TENT-2P');   // logs "reserve TENT-2P", disposes with "release TENT-2P"
  using b = reserve('MUG-SET');   // logs "reserve MUG-SET", disposes with "release MUG-SET"
  throw new Error('card declined');
}
try { checkout(); } catch {}
Question 2 of 4

Which value can be bound with using?

Question 3 of 4

How does a standard method decorator receive the method it decorates?

Question 4 of 4

Your codebase uses NestJS-style parameter decorators like constructor(@Inject(TOKEN) svc). What does that mean for your tsconfig?

Practice

Code playground

Release the stock, retry the charge

Two fixes for Cartwheel’s checkout:

  1. Make reserve return a disposable (with a [Symbol.dispose] method that logs release <sku> instead of a release method), and rewrite placeOrder with using so every reservation is released when the function exits, in reverse order, even when the charge throws.
  2. Write a typed decorator factory retry(times) and apply @retry(3) to PaymentGateway.charge, so a charge that throws is attempted up to 3 times in total before the error propagates. Keep charge’s signature intact.

The top of the file declares Symbol.dispose and Disposable, because the playground uses the ES2022 lib. DisposableStack is not available here; if you need one, write a small class of your own.

// ---- Playground shim (do not edit) ----
// The playground's lib is ES2022. These lines add what lib.esnext.disposable
// would declare, and make sure Symbol.dispose exists at runtime.
interface SymbolConstructor { readonly dispose: unique symbol; }
interface Disposable { [Symbol.dispose](): void; }
(Symbol as { dispose?: symbol }).dispose ??= Symbol.for('Symbol.dispose');
// ----------------------------------------

const events: string[] = [];

function reserve(sku: string) {
  events.push(`reserve ${sku}`);
  return { sku, release: () => events.push(`release ${sku}`) };
}

class PaymentGateway {
  calls = 0;
  failuresLeft: number;

  constructor(failures: number) {
    this.failuresLeft = failures;
  }

  // Should be retried up to 3 times in total.
  charge(amountCents: number): string {
    this.calls++;
    if (this.failuresLeft > 0) {
      this.failuresLeft--;
      throw new Error('Gateway timeout');
    }
    events.push(`charge ${amountCents}`);
    return `receipt-${amountCents}`;
  }
}

function placeOrder(skus: string[], gateway: PaymentGateway): string {
  const reservations = skus.map((sku) => reserve(sku));
  const receipt = gateway.charge(skus.length * 1000);
  reservations.forEach((r) => r.release());
  return receipt;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<ReturnType<typeof reserve>[typeof Symbol.dispose], () => void>>;
type T2 = Expect<Equal<ReturnType<PaymentGateway['charge']>, string>>;

console.log(placeOrder(['TENT-2P', 'MUG-SET'], new PaymentGateway(0)), events);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including the type tests
  • Reservations are released after the charge, in reverse order
  • Reservations are released even when the charge finally fails
  • charge succeeds on the third attempt after two failures
  • charge gives up and rethrows after 3 attempts