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 class

A 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 true

One base class does the boilerplate, subclasses only carry data.

TypeMeansWho fixes itRetry?
ValidationErrorthe input was wrongthe caller, by sending better datano
AuthErrornot signed in, or not allowedthe user, by logging inno
NotFoundErrorthe thing does not existnobody, it is a normal answerno
ConflictErrorthe state moved under youthe caller, by reloading firstsometimes
RateLimitErroryou asked too oftenthe caller, by waitingyes, with backoff
UpstreamErrora service you depend on failedoperationsyes
TypeError from your guardsa bug in the calling codeyou, in the sourcenever

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 end

Walking 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.

extend
class X extends Error, then super(message, options)
name
set this.name (or new.target.name in a base class)
narrow
err instanceof X, not err.message.includes(...)
across a boundary
a stable err.code string, because instanceof will not survive
context
new Error(msg, { cause: err }) at every layer that knows something new
many at once
new AggregateError(errors, msg), read .errors
logging
convert to a plain object first, JSON.stringify sees 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, name and stack are non-enumerable own or inherited properties, so JSON.stringify finds nothing to serialise. Build a plain object yourself before logging.
Why is err instanceof NotFoundError better than err.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 cause is 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 a cause can 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.any when every promise rejects — Promise.any only fails if every input fails, so there is no single error to report. It collects them all in err.errors. Promise.all rejects with the first error instead.

Common mistakes

  • Forgetting this.name, so every custom error still logs as Error.
  • Forgetting super(message), which leaves err.message empty.
  • Branching on err.message text, then breaking every handler with a wording change.
  • Relying on instanceof across 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 Error with super(message) and this.name set.
  • 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.
  • AggregateError exists 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.