Explicit Resource Management

Mental model: using is a finally block you cannot forget to write: the resource itself carries the instructions for releasing it, and the block exit runs them.

Level: advanced · about 16 minutes

Every resource you acquire has a release step, and every release step is one early return away from being skipped. try/finally solves it correctly and reads badly: three resources means three levels of nesting and three chances to get the order wrong. Explicit resource management moves the release step onto the resource, and gives the language a way to run it.

try/finally, three deep

const conn = openConnection();
try {
  const file = openFile('a.txt');
  try {
    const lock = acquireLock();
    try {
      work(conn, file, lock);
    } finally {
      lock.release();
    }
  } finally {
    file.close();
  }
} finally {
  conn.close();
}

using

{
  using conn = openConnection();
  using file = openFile('a.txt');
  using lock = acquireLock();

  work(conn, file, lock);
}
// lock, then file, then conn:
// released in reverse order,
// on every exit path

Same guarantees, one level of nesting, and the order cannot drift because it is derived from declaration order. The release logic now lives with the resource instead of at every call site.

The protocol

function openLogger(name) {
  const lines = [];
  return {
    name,
    log: (text) => lines.push(text),
    read: () => lines.slice(),
    [Symbol.dispose]() {
      lines.push(`[${name} closed]`);
      console.log('disposing', name, '->', lines.join(' | '));
    },
  };
}

// Called by hand, so this runs on any engine:
const logger = openLogger('manual');
logger.log('hello');
logger[Symbol.dispose]();
// -> disposing manual -> hello | [manual closed]

A disposable resource is any object with a Symbol.dispose method. That is the whole contract.

let hasUsing = true;
try {
  new Function('using x = { [Symbol.dispose]() {} };');
} catch {
  hasUsing = false;
}

console.log('this engine supports the using declaration:', hasUsing);

if (hasUsing) {
  const run = new Function(`
    const order = [];
    const res = (n) => ({ [Symbol.dispose]() { order.push('dispose ' + n); } });
    {
      using a = res('a');
      using b = res('b');
      order.push('body');
    }
    return order;
  `);
  console.log(run());   // -> [ 'body', 'dispose b', 'dispose a' ]
}

The same object with the syntax. Guarded, because not every engine has it yet.

  • using x = value binds like const: you cannot reassign x.
  • At block exit the engine calls x[Symbol.dispose](), on normal completion, return, break, continue and throw alike.
  • Multiple using declarations dispose in reverse order of declaration, like a stack unwinding.
  • null and undefined are allowed and simply do nothing, so an optional resource needs no branch.
  • Anything else without a Symbol.dispose method throws a TypeError at the declaration, not at the exit.
  • The method is looked up once, when the binding is created, so swapping it later has no effect.
const order = [];
function work() {
  using a = { [Symbol.dispose]() { order.push('a'); } };
  try {
    using b = { [Symbol.dispose]() { order.push('b'); } };
    return 'returned';
  } finally {
    order.push('finally');
  }
}

const result = work();
console.log(result, order.join(','));

The return first triggers the exit of the inner try block, which disposes b. Then the finally clause runs. Then the function body itself ends, which disposes a. So the order is b, finally, a. Disposal is tied to leaving the block that owns the declaration, and it happens before the enclosing finally because the inner block exits first.

Async resources

function openStream(name) {
  return {
    name,
    async [Symbol.asyncDispose]() {
      await new Promise((r) => setTimeout(r, 0));
      console.log(`${name} flushed and closed`);
    },
  };
}

async function main() {
  const stream = openStream('upload');
  try {
    console.log('writing');
  } finally {
    await stream[Symbol.asyncDispose]();   // await using does exactly this
  }
  console.log('after the block');
}

main();
// -> writing
// -> upload flushed and closed
// -> after the block

await using awaits the release. Shown here by hand so it runs anywhere.

usingawait using
method looked upSymbol.disposeSymbol.asyncDispose, then Symbol.dispose
result awaitednoyes
allowed inany blockonly where await is allowed
stack helperDisposableStackAsyncDisposableStack
typical resourcelock, subscription, timer, DOM listenerfile handle, socket, database transaction

DisposableStack: for cleanup you assemble at runtime

A using declaration needs to know its resource at the moment of declaration. When the set of things to clean up is built in a loop or a condition, you need a container. DisposableStack is that container, and it is itself disposable, so one using covers everything you put in it.

if (typeof DisposableStack !== 'function') {
  console.log('This engine has no DisposableStack yet.');
} else {
  const order = [];
  const stack = new DisposableStack();

  // 1. use: a resource that already has Symbol.dispose
  stack.use({ [Symbol.dispose]() { order.push('resource'); } });

  // 2. defer: an arbitrary callback
  stack.defer(() => order.push('deferred'));

  // 3. adopt: a value plus the function that releases it
  const handle = { id: 7 };
  stack.adopt(handle, (h) => order.push('adopted ' + h.id));

  stack.dispose();
  console.log(order);            // -> [ 'adopted 7', 'deferred', 'resource' ]
  stack.dispose();               // disposing twice is a no-op
  console.log(order.length);     // -> 3
}

use, defer, adopt, move: the four things a stack does.

if (typeof DisposableStack !== 'function') {
  console.log('This engine has no DisposableStack yet.');
} else {
  const closed = [];

  function openPair(shouldFail) {
    const stack = new DisposableStack();
    try {
      const a = stack.adopt({ name: 'a' }, (r) => closed.push(r.name));
      const b = stack.adopt({ name: 'b' }, (r) => closed.push(r.name));
      if (shouldFail) throw new Error('second step failed');
      return stack.move();          // success: ownership moves out, nothing disposed here
    } finally {
      stack.dispose();              // failure: everything acquired so far is released
    }
  }

  try { openPair(true); } catch (e) { console.log(e.message, 'closed:', closed.join(',')); }
  // -> second step failed closed: b,a

  const owned = openPair(false);
  console.log('nothing closed yet:', closed.length === 2);
  owned.dispose();
  console.log('closed after the caller disposes:', closed.join(','));
  // -> b,a,b,a
}

The ownership transfer pattern: clean up on failure, hand over on success.

When disposal itself throws

const err = new SuppressedError(
  new Error('close failed'),   // .error     the error from disposal
  new Error('body failed'),    // .suppressed the error already in flight
  'An error was suppressed during disposal'
);

console.log(err.constructor.name);   // -> SuppressedError
console.log(err.error.message);      // -> close failed
console.log(err.suppressed.message); // -> body failed

// A plain try/finally would have thrown away 'body failed' entirely,
// because a throw in finally replaces the original error.

SuppressedError keeps both errors, so neither is silently lost.

body throws, dispose is clean
the body error propagates
body is clean, dispose throws
the dispose error propagates
both throw
SuppressedError with .error (dispose) and .suppressed (body)
several disposals throw
they nest: each new error suppresses the accumulated one
ResourceReleaseDisposable wrapper worth writing?
AbortControllercontroller.abort()yes, one line
setInterval handleclearInterval(id)yes
DOM event listenerremoveEventListeneryes, or use { signal }
IntersectionObserverobserver.disconnect()yes
object URLURL.revokeObjectURL(url)yes, this one leaks silently
fetch response bodybody.cancel()yes, for streams you abandon
a plain objectnothingno

Try it yourself

Make a timer disposable

// Works today with no syntax support: the resource owns its cleanup.
function interval(fn, ms) {
  const id = setInterval(fn, ms);
  return {
    id,
    [Symbol.dispose]() {
      clearInterval(id);
      console.log('interval cleared');
    },
  };
}

function withResource(resource, work) {
  try {
    return work(resource);
  } finally {
    resource?.[Symbol.dispose]?.();
  }
}

let ticks = 0;
const result = withResource(interval(() => ticks++, 1000), (timer) => {
  console.log('timer id is a number:', typeof timer.id === 'number');
  return 'work done';
});

console.log(result, 'ticks:', ticks);
// -> interval cleared, then work done ticks: 0

Add a disposable wrapper for AbortController and prove the abort fires. Then make interval return the tick count when disposed.

Build the stack yourself

function makeStack() {
  const entries = [];
  let disposed = false;
  return {
    use(resource) {
      if (resource != null && typeof resource[Symbol.dispose] !== 'function') {
        throw new TypeError('not disposable');
      }
      if (resource != null) entries.push(() => resource[Symbol.dispose]());
      return resource;
    },
    defer(fn) {
      entries.push(fn);
    },
    adopt(value, onDispose) {
      entries.push(() => onDispose(value));
      return value;
    },
    dispose() {
      if (disposed) return;
      disposed = true;
      while (entries.length) entries.pop()();
    },
    get disposed() {
      return disposed;
    },
  };
}

const log = [];
const stack = makeStack();
stack.use({ [Symbol.dispose]() { log.push('used'); } });
stack.defer(() => log.push('deferred'));
stack.adopt({ n: 1 }, (v) => log.push('adopted ' + v.n));
stack.dispose();
stack.dispose();
console.log(log, stack.disposed);   // -> [ 'adopted 1', 'deferred', 'used' ] true

Add a move() that transfers the entries into a fresh stack and empties this one. Then make dispose collect every error instead of throwing the first.

Exercises

Build a DisposableStack

Write a class Scope with use(resource), defer(fn), adopt(value, onDispose), dispose(), a disposed getter, and a Symbol.dispose method that calls dispose. Everything is released in reverse order of registration. dispose() is idempotent. use returns its argument and throws a TypeError for a non-null value with no Symbol.dispose method (null and undefined are accepted and register nothing). If a release step throws, the remaining steps must still run, and the first error is rethrown afterwards.

await using, by hand

Write async function withAsyncCleanup(open, work). It awaits open() to get a resource, passes it to work(resource), awaits the result and returns it. Whatever happens, it then releases the resource by awaiting Symbol.asyncDispose if present, otherwise Symbol.dispose, otherwise nothing. If work rejects, the rejection must propagate after cleanup has completed.

Check yourself

In what order are these disposed?
c, b, a — Disposal is reverse declaration order, like a stack unwinding, so c, b, a. That order matters: if b was opened using a (a statement on a connection, say), releasing a first would break b's cleanup. Declaration order encodes the dependency, and reverse disposal respects it.
You write using conn = maybeNull() and the function returns null. What happens?
nothing special, null and undefined are allowed and dispose to a no-op — The spec explicitly allows null and undefined so optional resources do not need a branch. Any other value without a callable Symbol.dispose throws a TypeError immediately at the declaration, not at block exit, so you find out at acquisition time rather than during cleanup.
The body of a using block throws, and then the disposal throws too. What does the caller see?
a SuppressedError whose .error is the disposal error and whose .suppressed is the body error — This is the case a hand-written try/finally gets wrong: a throw in finally replaces the original error and the root cause disappears. SuppressedError keeps both, with the newer disposal error in .error and the in-flight body error in .suppressed. AggregateError is the Promise.any shape and is not used here.
When is DisposableStack the right tool instead of plain using declarations?
when the set of resources is decided at runtime, for example acquired in a loop or conditionally — A using declaration binds one known resource, so it cannot express "open one connection per shard". A stack collects them and is itself disposable, so a single using stack = new DisposableStack() covers the lot. Async cleanup is AsyncDisposableStack, and both still release in reverse order.

Common mistakes

  • Writing using at the top level of a classic script, which is a SyntaxError.
  • Expecting using to dispose at function exit when you declared it inside a narrower block.
  • Reassigning a using binding. It is const-like and will not let you.
  • Using using for something without Symbol.dispose and getting a TypeError at the declaration.
  • Forgetting await on await using, so cleanup races the code after the block.
  • Passing the disposal function without binding, so this inside it is undefined.
  • Assuming a DisposableStack that has been moved still owns anything. move() empties the source.
  • Relying on a finalizer for cleanup that using should be doing deterministically.

Takeaways

  • A disposable resource is any object with a Symbol.dispose (or Symbol.asyncDispose) method.
  • using x = value releases at block exit on every path, in reverse declaration order.
  • null and undefined are legal and dispose to nothing. Anything else non-disposable throws at the declaration.
  • await using awaits the release and requires an async context.
  • DisposableStack collects cleanup decided at runtime, with use, defer, adopt and move.
  • SuppressedError preserves both the body error and the disposal error instead of losing one.
  • V8 engines ship the syntax today, other engines lag, and the ownership pattern is worth adopting regardless.