In JavaScript, what does the options object in `new Error('load failed', { cause: err })` actually do, and how do you walk the resulting chain of causes?
answer
- second argument to the Error constructor
- wrapping without losing the original
- non-enumerable own property
- follow the link until absent
- subclass must forward to super
basics
~20 sThe ES2022 options bag installs a non-enumerable own cause property on the new error holding the original failure, so wrapping keeps the underlying error instead of discarding it. You read the chain by following err.cause repeatedly until it is absent.
solid answer
~50 sES2022 added a second parameter to `Error` and every built-in error constructor: an options object. If that object has a `cause` key, the engine installs `cause` as an own property of the new error — writable and configurable, but **non-enumerable**. The value can be anything, usually the error you caught. That lets you throw a message meaningful at your layer (`new Error('Could not load user', { cause: dbErr })`) without destroying the low-level detail. To consume it you walk the chain: `for (let e = err; e; e = e.cause) log(e.message)`, with a depth cap in case someone builds a cycle. Two traps: a custom subclass must forward the options bag to `super(message, options)` or the cause is silently lost, and because `cause` is non-enumerable, `JSON.stringify(err)` drops it — structured logging has to serialize the chain explicitly.
code
javascript · 23 linesclass DbError extends Error {
constructor(message, options) {
super(message, options); // forwarding installs cause
this.name = 'DbError';
}
}
function loadUser() {
try {
throw new TypeError('socket closed');
} catch (e) {
throw new DbError('Could not load user', { cause: e });
}
}
try {
loadUser();
} catch (err) {
for (let e = err, i = 0; e != null && i < 10; e = e.cause, i++) {
console.log(e instanceof Error ? `${e.name}: ${e.message}` : String(e));
}
console.log('cause survives JSON?', JSON.stringify(err));
}go deeper
Know that an error can carry the error that caused it, passed as new Error(msg, { cause: original }), and that you read it back as err.cause. Say plainly that this is how you wrap without discarding detail.
Be ready to state that the property is installed only when the options object actually has a cause key, that it is non-enumerable, and that a subclass constructor must forward the options bag to super. Show a traversal loop with a depth cap.
Demonstrate that you have hit this in production: chains disappearing from JSON logs, cause values that are not Errors, and the discipline of wrapping exactly once per boundary rather than at every frame so the chain stays readable.
Own the convention across services: which layers wrap and which rethrow, what the log serializer guarantees about chain depth, and how the chain maps onto whatever error identity or code the API contract exposes to callers.
## The problem it solves The classic wrapping anti-pattern looks like this: ```js try { return await db.query(sql); } catch (e) { throw new Error('Could not load user'); // original gone } ``` The caller now gets a message that is meaningful at the API layer, but the connection-refused detail, the SQL state and the original stack have all been thrown away. The opposite extreme — rethrowing the raw driver error — leaks a low-level failure into a high-level contract and gives the caller no idea which operation failed. Error chaining is the middle path: raise an error appropriate to your layer, and keep a machine-readable link to the one underneath. ## Exact semantics of the options bag ES2022 gave `Error` and every built-in error constructor (`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`, `AggregateError`) a second parameter. The spec step is precise: if that argument is an object **and** it has a property named `cause`, the engine defines an own `cause` property on the new error object with `{ writable: true, enumerable: false, configurable: true }`. Two consequences follow directly: ```js 'cause' in new Error('x'); // false — no options bag 'cause' in new Error('x', {}); // false — bag has no cause key 'cause' in new Error('x', { cause: undefined }); // true, value undefined ``` So "no cause" and "a cause that happens to be `undefined`" are distinguishable, which matters when you write a traversal that stops on absence rather than on falsiness. The value is unconstrained. It is idiomatic to pass the caught error, but a string, a response object, or a number are all legal — which is why consumers should not assume `err.cause.message` exists. ## Walking the chain Traversal is a loop, not a recursion into an unbounded structure: ```js function chain(err, max = 10) { const out = []; for (let e = err, i = 0; e != null && i < max; e = e.cause, i++) { out.push(e instanceof Error ? `${e.name}: ${e.message}` : String(e)); } return out; } ``` The depth cap matters because nothing in the language prevents `a.cause = b; b.cause = a`. A naive `while (e.cause)` on a cycle hangs the thread — and because JavaScript runs to completion on one thread, that is a hung request, not a slow one. ## Subclasses must forward the options The single most common bug with `cause` is a subclass that never passes it through: ```js class DbError extends Error { constructor(message, options) { super(message, options); // forwarding is what installs cause this.name = 'DbError'; } } ``` If the constructor signature is `(message, cause)` and the body calls `super(message)`, the cause is dropped without any error — callers pass it, nobody ever sees it. Assigning `this.cause = cause` by hand also works, but note that a plain assignment creates an *enumerable* property, so the two forms behave differently under `JSON.stringify` and object spread. ## Non-enumerability and logging Because `message`, `stack` and `cause` are all non-enumerable own properties, `JSON.stringify(err)` returns `{}` — the whole chain vanishes from a naive log line. Object spread and `Object.assign` copy nothing either. Most runtimes' console inspection does render the chain (Node prints a `[cause]` block, browser devtools show a nested error), which is exactly why the problem hides until logs are shipped as JSON. A structured logger needs an explicit serializer that pulls `name`, `message`, `stack` and then recurses into `cause`. ## Before ES2022, and what `cause` does not do The pattern predates the syntax: people wrote `err.cause = original`, or used wrapper libraries. What ES2022 changed is that there is now one agreed property name, so runtimes and tooling can render chains without knowing your conventions. What it does not do: - It does **not** merge stacks. The wrapper's `stack` contains only the wrapper's frames; the inner error's frames stay on the inner error. Any "full picture" comes from printing both. - It does **not** make errors serializable, as above. - It is a **single** link per error — a linear chain, not a collection. When several independent failures must be reported together, that is a different shape entirely. - It has no effect on control flow: nothing catches or filters on `cause` for you. If callers need to branch on the underlying failure, they have to walk the chain and test what they find.
- Why does `JSON.stringify` of an error with a cause produce `{}`, and what do you do about it in a structured logger?`message`, `stack` and `cause` are non-enumerable own properties, and `JSON.stringify` only visits enumerable string-keyed own properties, so nothing is emitted. The fix is an explicit serializer that reads those properties by name and recurses into `cause` with a depth cap, rather than relying on default serialization or on `toJSON`, which `Error` does not define.
- Does attaching a cause give the outer error a combined stack trace?No. Each error captured its own stack when it was constructed, and `cause` is just a property link — the outer error's `stack` string contains only the frames from where it was created. A reader gets the full picture only because the printer walks the chain and prints each error's own stack in turn.
- What can go wrong if a consumer walks the chain with `while (e.cause) e = e.cause`?Two things. Cycles are legal — nothing stops `a.cause = b; b.cause = a` — and that loop then never terminates, hanging the single thread. And a cause need not be an `Error`; it can be a string or a response object, so any code that then reads `e.message` or `e.stack` gets `undefined`. Cap the depth and type-check each link.
saying these in an interview costs you the question
- Thinks cause merges the two stack traces into one
- Says JSON.stringify will log the cause chain
- Subclass constructor calls super(message) and drops options
- Assumes cause is always an Error instance
- Walks the chain with no depth cap or cycle guard