Pass error context to Symbol.dispose
TLDR:
const testLogger = () => {
const logs = []
return {
log: (...args) => logs.push(args),
[Symbol.dispose]: ({ errors }) => {
if (errors.length) {
console.log(`Error encountered (${errors.join(",")}). Here's what you logged along the way`)
logs.forEach(args => console.log(...args))
}
},
}
}
test("my test", () => {
using logger = testLogger()
logger.log("Started")
doStuff()
logger.log("Did stuff")
doMore()
logger.log("Did more")
// If an error is thrown, you'll see the logs. If not, blissful quiet.
})
Details below, including how to handle multiple disposables (which could throw errors in their disposers)
Problem
When [Symbol.dispose]() is called, we don’t know if we’re cleaning up after a failure. This limits the usefulness of using for a class of legitimate patterns where cleanup behaviour should differ depending on whether an error occurred.
The workaround today is to manually set a flag before the block exits successfully:
using tx = db.beginTransaction();
// ... do work ...
tx.commit(); // sets an internal flag
// [Symbol.dispose]() checks the flag: if not committed, rollback
This works, but it undermines the value proposition of using. The commit and rollback logic are separated, and you have to commit manually and keep track of state to get the logic right.
Proposal
Pass a DisposeContext object to [Symbol.dispose]() and [Symbol.asyncDispose]():
interface DisposeContext {
errors: unknown[]; // all errors so far, in order: body error (if any), then disposal errors
}
interface Disposable {
[Symbol.dispose](ctx?: DisposeContext): void;
}
interface AsyncDisposable {
[Symbol.asyncDispose](ctx?: DisposeContext): Promise<void>;
}
errors is a flat array of everything that has gone wrong so far. If the block body threw, that error is first. Errors thrown by earlier disposals (those disposed before the current one) follow in order.
Distinguishing body errors from disposal errors: DisposeError
To help distinguish between errors thrown in the body of the block and errors thrown in other disposers, the proposal introduces a new built-in error type, DisposeError, which wraps any error thrown during a disposal call:
class DisposeError extends Error {
constructor(cause: unknown) {
super("An error occurred during resource disposal", { cause });
this.name = "DisposeError";
}
}
The runtime wraps disposal errors in DisposeError before appending them to the errors array. Body errors are never wrapped. This makes the array self-describing:
-
errors.length > 0- did anything go wrong? -
errors.some(e => !(e instanceof DisposeError))- did the body throw? -
errors.filter(e => e instanceof DisposeError)- which disposals failed?
What this doesn't change
This proposal does not change SuppressedError or the error that callers catch. The SuppressedError chaining mechanism is the external-facing error type for catch blocks and remains untouched. DisposeContext is the internal-facing context for [Symbol.dispose] implementations, a different audience with different needs.
The dispose method cannot suppress, substitute, or otherwise alter the error. This is the key difference from tc39/proposal-explicit-resource-management#49, which proposed error suppression via return value. However, this proposal is deliberately designed not to preclude suppression being added in a future follow-on. The errors array gives disposables the context to decide whether to suppress, and a separate mechanism (e.g. a return value convention) could later provide the means to act on that decision.
Motivating use case: test diagnostics
A test fixture that collects diagnostic information during a test and dumps it on failure:
class TestLog {
#entries: Array<{ label: string; value: unknown }> = [];
log(label: string, value: unknown) {
this.#entries.push({ label, value });
}
[Symbol.dispose]({ errors }: DisposeContext) {
if (errors.length > 0) {
console.error("\n🔴 Test failed - collected diagnostics:");
for (const { label, value } of this.#entries) {
console.error(` ${label}:`, value);
}
const disposeErrors = errors.filter(e => e instanceof DisposeError);
if (disposeErrors.length > 0) {
console.error(` ⚠️ ${disposeErrors.length} disposal error(s) also occurred`);
}
}
}
}
test("user signup sends welcome email", async () => {
using logger = new TestLog();
const user = await createTestUser({ email: "alice@test.com" });
logger.log("created user", { id: user.id, email: user.email });
const res = await api.post("/signup", { userId: user.id });
logger.log("signup response", { status: res.status, body: res.body });
const emails = await mailbox.getEmails(user.email);
logger.log("mailbox contents", emails);
expect(emails).toHaveLength(1);
// On failure: prints all diagnostics. On success: silence.
});
There's no clean way to express this today. The "set a flag" pattern doesn't apply, since there's no single success action to mark. You'd need to wrap the entire test body in try/catch yourself, which defeats the purpose of using.
Transactions: body-aware commit/rollback
class Transaction {
#conn: Connection;
constructor(conn: Connection) {
this.#conn = conn;
}
async [Symbol.asyncDispose]({ errors }: DisposeContext) {
const bodyFailed = errors.some(e => !(e instanceof DisposeError));
if (bodyFailed) {
await this.#conn.query("ROLLBACK");
} else {
await this.#conn.query("COMMIT");
}
}
}
// No manual .commit() call needed
async function transferFunds(from: string, to: string, amount: number) {
await using tx = db.beginTransaction();
await using auditLog = new AuditLog("transfer", { from, to, amount });
await tx.debit(from, amount);
await tx.credit(to, amount);
// If the body succeeds but auditLog's disposal throws (e.g. failed to flush
// to an external logging service), the transaction still sees no body error
// and commits. This application is choosing to treat audit log failure as
// non-critical. But that's a choice: a stricter application could check
// errors.length > 0 and rollback on *any* error, body or disposal.
}
Note that the transaction commits even if an earlier disposal threw. It only rolls back if the body itself failed. DisposeError makes this distinction possible.
Other use cases
-
Performance instrumentation: record timings or tag spans with error metadata only on failure.
-
Temporary files: delete temp files on success, preserve for debugging on failure.
Multiple disposables: how the errors array evolves
using logger = new TestLog(); // disposed 3rd
using upstream = new TestServer(4001); // disposed 2nd
using proxy = new TestServer(4002); // disposed 1st
throw undefined;
// proxy sees: { errors: [undefined] }
// proxy.close() throws Error("already closed")
// upstream sees: { errors: [undefined, DisposeError(cause: Error("already closed"))] }
// upstream succeeds
// logger sees: { errors: [undefined, DisposeError(cause: Error("already closed"))] }
Note that throw undefined works correctly. errors contains [undefined], and errors.length > 0 is still true. No separate hasError flag needed.
I put errors as a property in a parameter object in case we want to pass in any other context info in a future proposal.
Prior art
-
Python's
__exit__(self, exc_type, exc_val, exc_tb): receives error context. Also allows suppression via returningTrue, which is widely considered a footgun. This proposal deliberately omits that capability. -
tc39/proposal-explicit-resource-management#49: raised in 2020, labelled
for follow-on proposal. That issue proposed error suppression/substitution via return value. This proposal is intentionally narrower: context is read-only.
Backward compatibility
-
Existing
[Symbol.dispose]()implementations that take zero arguments continue to work. The context argument is simply ignored. -
No new control flow semantics.
SuppressedErrorchaining is unchanged.
DisposeError summary
DisposeError is a new built-in Error subclass. It serves as a tag type. The runtime wraps errors thrown during disposal in DisposeError so that disposables receiving the errors array can distinguish body errors from disposal errors. Its only property beyond the standard Error fields is cause, following the existing Error(message, { cause }) convention.
Open questions
-
Should
DisposableStack.prototype.dispose(ctx?)andAsyncDisposableStack.prototype.disposeAsync(ctx?)also accept an optional context, or should this only apply to implicit disposal fromusingblocks? -
Should the
DisposeContextobject be frozen by the runtime? -
Naming:
DisposeContextvsDisposalContextvs something else?DisposeErrorvs alternatives?