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 pathSame 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 = valuebinds likeconst: you cannot reassignx.- At block exit the engine calls
x[Symbol.dispose](), on normal completion,return,break,continueandthrowalike. - Multiple
usingdeclarations dispose in reverse order of declaration, like a stack unwinding. nullandundefinedare allowed and simply do nothing, so an optional resource needs no branch.- Anything else without a
Symbol.disposemethod throws aTypeErrorat 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 blockawait using awaits the release. Shown here by hand so it runs anywhere.
using | await using | |
|---|---|---|
| method looked up | Symbol.dispose | Symbol.asyncDispose, then Symbol.dispose |
| result awaited | no | yes |
| allowed in | any block | only where await is allowed |
| stack helper | DisposableStack | AsyncDisposableStack |
| typical resource | lock, subscription, timer, DOM listener | file 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 throwSuppressedErrorwith.error(dispose) and.suppressed(body)several disposals throw- they nest: each new error suppresses the accumulated one
| Resource | Release | Disposable wrapper worth writing? |
|---|---|---|
AbortController | controller.abort() | yes, one line |
setInterval handle | clearInterval(id) | yes |
| DOM event listener | removeEventListener | yes, or use { signal } |
IntersectionObserver | observer.disconnect() | yes |
| object URL | URL.revokeObjectURL(url) | yes, this one leaks silently |
fetch response body | body.cancel() | yes, for streams you abandon |
| a plain object | nothing | no |
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: ifbwas opened usinga(a statement on a connection, say), releasingafirst would breakb's cleanup. Declaration order encodes the dependency, and reverse disposal respects it. - You write
using conn = maybeNull()and the function returnsnull. What happens? - nothing special,
nullandundefinedare allowed and dispose to a no-op — The spec explicitly allowsnullandundefinedso optional resources do not need a branch. Any other value without a callableSymbol.disposethrows aTypeErrorimmediately at the declaration, not at block exit, so you find out at acquisition time rather than during cleanup. - The body of a
usingblock throws, and then the disposal throws too. What does the caller see? - a
SuppressedErrorwhose.erroris the disposal error and whose.suppressedis the body error — This is the case a hand-writtentry/finallygets wrong: a throw infinallyreplaces the original error and the root cause disappears.SuppressedErrorkeeps both, with the newer disposal error in.errorand the in-flight body error in.suppressed.AggregateErroris thePromise.anyshape and is not used here. - When is
DisposableStackthe right tool instead of plainusingdeclarations? - when the set of resources is decided at runtime, for example acquired in a loop or conditionally — A
usingdeclaration binds one known resource, so it cannot express "open one connection per shard". A stack collects them and is itself disposable, so a singleusing stack = new DisposableStack()covers the lot. Async cleanup isAsyncDisposableStack, and both still release in reverse order.
Common mistakes
- Writing
usingat the top level of a classic script, which is aSyntaxError. - Expecting
usingto dispose at function exit when you declared it inside a narrower block. - Reassigning a
usingbinding. It isconst-like and will not let you. - Using
usingfor something withoutSymbol.disposeand getting aTypeErrorat the declaration. - Forgetting
awaitonawait using, so cleanup races the code after the block. - Passing the disposal function without binding, so
thisinside it is undefined. - Assuming a
DisposableStackthat has been moved still owns anything.move()empties the source. - Relying on a finalizer for cleanup that
usingshould be doing deterministically.
Takeaways
- A disposable resource is any object with a
Symbol.dispose(orSymbol.asyncDispose) method. using x = valuereleases at block exit on every path, in reverse declaration order.nullandundefinedare legal and dispose to nothing. Anything else non-disposable throws at the declaration.await usingawaits the release and requires an async context.DisposableStackcollects cleanup decided at runtime, withuse,defer,adoptandmove.SuppressedErrorpreserves 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.