Passing error context to Symbol.dispose

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 returning True, 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. SuppressedError chaining 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

  1. Should DisposableStack.prototype.dispose(ctx?) and AsyncDisposableStack.prototype.disposeAsync(ctx?) also accept an optional context, or should this only apply to implicit disposal from using blocks?

  2. Should the DisposeContext object be frozen by the runtime?

  3. Naming: DisposeContext vs DisposalContext vs something else? DisposeError vs alternatives?