Custom Errors and cause
Mental model: An error type is a label your handlers can match on. cause is the chain of "and this is why" that leads back to the first failure.
Level: intermediate · about 15 minutes
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
function setAge(value) {
if (!Number.isInteger(value)) {
throw new ValidationError('age must be a whole number', 'age');
}
return value;
}
try {
setAge('42');
} catch (err) {
console.log(err instanceof ValidationError); // -> true
console.log(err instanceof Error); // -> true
console.log(err.name, '|', err.field); // -> ValidationError | age
}One class, and suddenly the handler can ask a question instead of guessing.
A custom error is just a class that extends Error. You get everything the built-in gives you (message, stack, instanceof Error) plus two things that matter more: a type your handlers can match on, and room for extra fields like field, status or retryable.
class NotFoundError extends Error {}
const err = new NotFoundError('no such user');
console.log(err.name);name is a plain inherited property on Error.prototype, so an empty subclass reports 'Error'. The class name is available as err.constructor.name, but nothing copies it across for you. Set this.name in the constructor.
Why instanceof beats matching on the message
Matching on text
catch (err) {
if (err.message.includes('not found')) {
return null;
}
throw err;
}
// breaks when someone rewords
// the message, and matches any
// error that happens to say it
Matching on type
catch (err) {
if (err instanceof NotFoundError) {
return null;
}
throw err;
}
// the message is free to change,
// the contract is the classA message is for a human reading a log. A type (or a stable code string) is for code making a decision. Once you match on text, every message becomes an API you cannot change.
An error taxonomy for an app
class AppError extends Error {
constructor(message, options) {
super(message, options); // passes { cause } straight through
this.name = new.target.name; // the subclass being constructed
}
}
class ValidationError extends AppError { status = 400; retryable = false; }
class NotFoundError extends AppError { status = 404; retryable = false; }
class UpstreamError extends AppError { status = 502; retryable = true; }
const err = new NotFoundError('user 42 does not exist');
console.log(err.name, err.status, err.retryable); // -> NotFoundError 404 false
console.log(err instanceof AppError, err instanceof Error); // -> true trueOne base class does the boilerplate, subclasses only carry data.
| Type | Means | Who fixes it | Retry? |
|---|---|---|---|
ValidationError | the input was wrong | the caller, by sending better data | no |
AuthError | not signed in, or not allowed | the user, by logging in | no |
NotFoundError | the thing does not exist | nobody, it is a normal answer | no |
ConflictError | the state moved under you | the caller, by reloading first | sometimes |
RateLimitError | you asked too often | the caller, by waiting | yes, with backoff |
UpstreamError | a service you depend on failed | operations | yes |
TypeError from your guards | a bug in the calling code | you, in the source | never |
Keep the list this short. Six or seven types cover almost every application, and the useful split is not "which line failed" but "who can do something about it". If two error types would always be handled identically, they are one type.
cause: keep the original failure
class ConfigError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'ConfigError';
}
}
function readConfig(text) {
try {
return JSON.parse(text);
} catch (err) {
throw new ConfigError('config.json could not be read', { cause: err });
}
}
try {
readConfig('{ oops }');
} catch (err) {
console.log(err.name, '|', err.message); // -> ConfigError | config.json could not be read
console.log(err.cause.name); // -> SyntaxError
}The second argument to Error is an options object (ES2022).
const low = new RangeError('offset 9 is past the end');
const mid = new Error('failed to decode frame 3', { cause: low });
const top = new Error('video import failed', { cause: mid });
let current = top;
let depth = 0;
while (current) {
console.log(' '.repeat(depth++) + current.name + ': ' + current.message);
current = current.cause;
}
// -> Error: video import failed
// -> Error: failed to decode frame 3
// -> RangeError: offset 9 is past the endWalking the chain outward-in is how you find the line that actually broke.
AggregateError: many failures, one throw
const failures = [
new RangeError('quantity must be positive'),
new TypeError('sku must be a string'),
];
const agg = new AggregateError(failures, 'the order has 2 problems');
console.log(agg.name, '|', agg.message); // -> AggregateError | the order has 2 problems
console.log(agg instanceof Error); // -> true
for (const err of agg.errors) {
console.log('-', err.name + ':', err.message);
}When "which one failed" is the wrong question, because all of them did.
The language throws one at you too: Promise.any rejects with an AggregateError whose errors array holds every rejection, in the order the promises were passed. Reach for it yourself whenever collecting all the failures is more useful than stopping at the first one, which is exactly the situation form validation is in.
Serialising an error for a log
console.log(JSON.stringify(new Error('boom'))); // -> {}
console.log(JSON.stringify({ err: new TypeError('nope') })); // -> {"err":{}}
console.log(Object.keys(new Error('boom'))); // -> []Run this before you trust your logging pipeline.
function serialiseError(err, depth = 3) {
if (!(err instanceof Error)) return { name: 'NonError', message: String(err) };
const out = { name: err.name, message: err.message };
for (const key of ['code', 'status', 'field']) {
if (err[key] !== undefined) out[key] = err[key];
}
if (err.stack) out.stack = err.stack.split('\n').slice(0, 3);
if (err.cause !== undefined && depth > 0) out.cause = serialiseError(err.cause, depth - 1);
return out;
}
const err = new Error('checkout failed', { cause: new RangeError('cart is empty') });
err.code = 'CHECKOUT_FAILED';
console.log(JSON.stringify(serialiseError(err, 2), null, 2));A serialiser that keeps the chain and cannot loop forever.
extendclass X extends Error, thensuper(message, options)name- set
this.name(ornew.target.namein a base class) narrowerr instanceof X, noterr.message.includes(...)across a boundary- a stable
err.codestring, becauseinstanceofwill not survive contextnew Error(msg, { cause: err })at every layer that knows something newmany at oncenew AggregateError(errors, msg), read.errorslogging- convert to a plain object first,
JSON.stringifysees nothing otherwise
Try it yourself
Build a taxonomy and route on it
class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name;
}
}
class ValidationError extends AppError { status = 400; }
class NotFoundError extends AppError { status = 404; }
class UpstreamError extends AppError { status = 502; }
function toResponse(err) {
if (err instanceof ValidationError) return { code: err.status, body: err.message };
if (err instanceof NotFoundError) return { code: err.status, body: 'not found' };
if (err instanceof UpstreamError) return { code: err.status, body: 'try again shortly' };
return { code: 500, body: 'internal error' };
}
console.log(toResponse(new ValidationError('email is required')));
console.log(toResponse(new NotFoundError('user 42')));
console.log(toResponse(new Error('a bug in my own code')));
Add a RateLimitError with a retryAfter field and give the router a branch for it. Then try removing this.name and see what the log looks like.
Walk a cause chain
function chain(err) {
const out = [];
let current = err;
while (current) {
out.push(current.name + ': ' + current.message);
current = current.cause;
}
return out;
}
const io = new Error('EACCES: permission denied');
const load = new Error('could not read theme.json', { cause: io });
const boot = new Error('app failed to start', { cause: load });
console.log(chain(boot).join('\n caused by '));
Give the chain a non-Error cause (a plain string) and watch the walker break. Then make it survive that.
Exercises
Give your errors a type
Define ValidationError extending Error with name set to 'ValidationError' and a field property. Then write validateQuantity(input): return the number when input is an integer of 1 or more, otherwise throw a ValidationError with the message 'quantity must be a whole number of 1 or more' and field set to 'quantity'.
Flatten a cause chain
Write describeError(err) returning an array of { name, message } objects, outermost error first, following cause all the way down. A link that is not an Error becomes { name: 'NonError', message: String(value) }. Stop after 5 entries so a cycle cannot hang the logger.
Check yourself
- What does this log?
{}—message,nameandstackare non-enumerable own or inherited properties, soJSON.stringifyfinds nothing to serialise. Build a plain object yourself before logging.- Why is
err instanceof NotFoundErrorbetter thanerr.message.includes('not found')? - The message is for humans and free to change, the class is a stable contract for code — Matching on text turns every message into an API: reword it and the handler silently stops matching. It also matches unrelated errors that happen to contain the phrase. Match on a type or a stable
code. - Which statement about
causeis true? - You pass it yourself as
new Error(msg, { cause: err }), and it can be any value — Nothing chains errors for you. You opt in with the options object, and no validation happens, so acausecan be a string, a response object or anything else. Code that walks the chain has to check the type. - Which built-in API rejects with an
AggregateError? Promise.anywhen every promise rejects —Promise.anyonly fails if every input fails, so there is no single error to report. It collects them all inerr.errors.Promise.allrejects with the first error instead.
Common mistakes
- Forgetting
this.name, so every custom error still logs asError. - Forgetting
super(message), which leaveserr.messageempty. - Branching on
err.messagetext, then breaking every handler with a wording change. - Relying on
instanceofacross a module or process boundary, where the class identity differs. - Passing errors straight to a JSON logger and getting
{}. - Building fifteen error classes when three would be handled identically.
Takeaways
- A custom error is a class extending
Errorwithsuper(message)andthis.nameset. - Types (or stable codes) are for code to branch on, messages are for humans to read.
new Error(msg, { cause: err })keeps the original failure, but only if you pass it.AggregateErrorexists for when the useful answer is "all of them failed".- Errors do not serialise to JSON on their own, so convert them deliberately before logging.
- A short taxonomy grouped by "who can fix this" beats a long one grouped by where it happened.