JAVASCRIPT / ERRORS, STRICT MODE, AND DEBUGGING
Custom error classes and the cause chain
Define Error subclasses that carry your own fields, wrap lower-level failures with { cause }, and walk the resulting chain back to the root failure.
What you will learn
- Subclass Error with constructor(message, options) and forward options into super()
- Set this.name and add domain fields so callers branch on data, not on message text
- Rethrow with { cause: err } instead of interpolating the caught error into a message
- Walk err.cause in a loop to reach the root, and use instanceof to classify each link
Understanding Custom error classes and the cause chain
An Error subclass exists so that catching code can decide what to do without reading English. Writing class ValidationError extends Error gives you a type that instanceof recognizes anywhere in the prototype chain, and the constructor is where you attach the machine-readable parts of the failure: the field that was invalid, the status you intend to return, the line number. Setting this.name explicitly matters because name is otherwise inherited from Error.prototype as the string Error, and toString plus most log formatters read it off the instance. The message stays for humans; the class and the extra fields are for the code that catches.
The second argument to the Error constructor is an options object whose only recognized key is cause, so new Error('profile unavailable', { cause: err }) stores err on the new error as a non-enumerable cause property. That lets each layer describe the problem in its own words while keeping the failure underneath intact, so the chain reads from the most abstract statement of what went wrong down to the timeout or SyntaxError that actually happened. Because cause is an ordinary property, walking the chain is just a loop: follow err.cause until it is undefined. Nothing requires the value to be an Error, so that loop should be guarded by reaching the end rather than by a type check.
Two mechanical details decide whether this works in practice. First, cause is installed by the Error constructor from the arguments it actually receives, so every subclass in the hierarchy must forward its options object into super; a constructor that accepts options and then calls super(message) alone drops the cause without complaint. Second, message, stack and cause are all non-enumerable, which is why JSON.stringify on an error returns nothing but the fields you assigned yourself, and why shipping errors to a logger or over a network needs a toJSON method or an explicit formatter.
class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'AppError';
}
}
class ConfigError extends AppError {
constructor(key, options) {
super(`missing config key "${key}"`, options);
this.name = 'ConfigError';
this.key = key;
}
}
function readPort(text) {
try {
return JSON.parse(text).port;
} catch (err) {
throw new ConfigError('port', { cause: err });
}
}
try {
readPort('{ port: 8080 }');
} catch (err) {
console.log(err.name + ': ' + err.message);
console.log('is ConfigError:', err instanceof ConfigError);
console.log('is AppError:', err instanceof AppError);
console.log('field:', err.key);
console.log('root:', err.cause.name);
}A thrown error should be a typed object whose class says what kind of failure it is and whose cause preserves the failure that triggered it, so no layer has to choose between adding context and keeping detail.
Worked examples
Walking the chain to the root
Three wrapped errors traversed as a linked list until cause runs out.
const root = new RangeError('offset 40 is past the end of the buffer');
const mid = new Error('could not decode frame 7', { cause: root });
const top = new Error('stream aborted', { cause: mid });
const links = [];
for (let e = top; e; e = e.cause) {
links.push(e.name + ': ' + e.message);
}
console.log(links.join('\n caused by '));
console.log('root has a cause property?', 'cause' in root);Example explained
Line 1for (let e = top; e; e = e.cause) treats the errors as a linked list; the loop stops when cause is undefined.
Line 2root was constructed without an options object, so 'cause' in root is false: the property was never created at all.
Line 3Each link reports its own name, which is why RangeError still identifies itself after two layers of wrapping.
Deciding retryability from the chain
Two errors with identical messages get different handling because the links below them differ.
class HttpError extends Error {
constructor(status, options) {
super('HTTP ' + status, options);
this.name = 'HttpError';
this.status = status;
}
}
class TimeoutError extends Error {
constructor(ms) {
super('timed out after ' + ms + 'ms');
this.name = 'TimeoutError';
this.ms = ms;
}
}
function isRetryable(err) {
for (let e = err; e; e = e.cause) {
if (e instanceof TimeoutError) return true;
if (e instanceof HttpError && e.status >= 500) return true;
}
return false;
}
const slow = new Error('profile unavailable', {
cause: new HttpError(503, { cause: new TimeoutError(2000) })
});
const missing = new Error('profile unavailable', { cause: new HttpError(404) });
console.log('slow:', isRetryable(slow));
console.log('missing:', isRetryable(missing));
console.log('two levels down:', slow.cause.status, slow.cause.cause.name);Example explained
Line 1isRetryable never inspects a message; it asks instanceof and reads status, so rewording an error cannot change the decision.
Line 2The 503 link satisfies e.status >= 500 while the 404 link does not, which is why the two identical outer messages diverge.
Line 3TimeoutError sits two links down and is still reachable, because wrapping adds a link instead of replacing the previous error.
Line 4status and ms are plain instance fields, so the subclass carries structured data the base Error has no place for.
Why logging an error looks empty
Non-enumerable built-in properties disappear under JSON.stringify unless the class defines toJSON.
class ParseError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'ParseError';
this.line = options?.line;
}
toJSON() {
return {
name: this.name,
message: this.message,
line: this.line,
cause: this.cause ? { name: this.cause.name, message: this.cause.message } : null
};
}
}
const err = new ParseError('unexpected token at column 4', {
line: 12,
cause: new TypeError('tokens is not iterable')
});
console.log(JSON.stringify(new Error('build failed', { cause: err })));
console.log(JSON.stringify(err));
console.log(Object.keys(err).join(','));Example explained
Line 1The first line prints {} because message, stack and cause are non-enumerable, so JSON.stringify finds nothing to copy.
Line 2Object.keys lists only name and line, the two properties assigned by the constructor, which is why hand-set fields survive serialization.
Line 3The options bag carries line next to cause; the Error constructor reads only cause and ignores the rest, so one object serves both.
Line 4toJSON flattens one level of the chain deliberately, avoiding an unbounded nested structure in the log record.
Important notes
cause may hold any value, not only an Error, and 'cause' in err is what distinguishes no cause from { cause: undefined }; a chain walker should also guard against a cycle if code ever assigns cause after construction.
instanceof compares against one realm's class object, so errors crossing an iframe, a worker, or a duplicated bundled module will fail the check even though the class looks identical; in that situation compare an explicit code field instead.
Common mistakes
Writing constructor(message, options) { super(message); } — the options bag is accepted and then thrown away, so err.cause is undefined even though the call site clearly passed a cause, and the original failure is unrecoverable.
Folding the caught error into the message, as in throw new AppError('load failed: ' + err) — the original type, fields and stack become plain text, so nothing downstream can branch on what actually failed.
Classifying with err.name === 'ValidationError' or err.constructor === ValidationError — a FieldError that extends ValidationError fails both checks, while instanceof matches the whole prototype chain.
Try it yourself
Change, predict, then run
In a browser console, write class CacheError extends Error whose constructor takes (key, options), forwards options to super, and sets this.name and this.key, then throw it with { cause: new TypeError('entry is not an object') } and print every link as name: message with a while loop. Then delete options from the super call and note which line of your output disappears.
Open the JavaScript workspaceCheck your understanding
A subclass is written as constructor(message, options) { super(message); this.name = 'DbError'; } and callers throw new DbError('query failed', { cause: syntaxError }). What is err.cause in the handler?
- undefined, because the constructor received options but never handed it to super
- The SyntaxError, because passing { cause } at the call site is what installs the property
- undefined, because cause only works on Error itself and is not inherited by subclasses
- The string 'SyntaxError: ...', because cause is coerced to text when the error is constructed
Show answer
cause is installed by the Error constructor from the second argument it actually receives; here the subclass swallowed options and called super(message) alone, so no cause property was ever created. Option 1 is tempting because the call site looks correct, but a call-site options object only reaches the base class if every constructor in between forwards it. Subclasses do inherit the behaviour, so option 2 is wrong — the wiring simply has to be explicit.