Throwing plain strings or objects loses a stack trace and any reliable way to branch in a catch. A small custom error class keeps a stable name, supports instanceof, and can carry a cause that records the original failure without hiding it.
Before you start
You should be comfortable with class, extends, super and try...catch. This article focuses on the error contract; it does not cover logging pipelines or framework error middleware.
Step-by-step walkthrough
Step 1: Extend the built-in Error
Subclass Error so instances still carry message and stack. Set this.name to the class name: the default Error name would otherwise appear in logs and stack traces, making the subclass indistinguishable from a generic failure.
Step 2: Preserve the cause
Pass { cause } through to super. The cause property keeps the underlying error available for diagnostics while the outer error describes the layer that failed, for example a ValidationError caused by a schema mismatch. Callers can branch on the outer type and still log the inner one.
Step 3: Keep the hierarchy meaningful
Model error categories that callers actually branch on, not one class per message. A catch that tests instanceof AppError should be able to handle every expected application failure, while transport or programming errors stay outside that hierarchy.
Worked scenario
Run this with Node.js. The subclass keeps instanceof for both itself and Error.
class ValidationError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'ValidationError';
}
}
try {
throw new ValidationError('Invalid email', { cause: new Error('schema mismatch') });
} catch (error) {
console.log(error instanceof ValidationError); // true
console.log(error instanceof Error); // true
console.log(error.name); // ValidationError
console.log(error.cause.message); // schema mismatch
}Walk through the example
super(message, options) sets message, stack and cause. Setting name makes logs read ValidationError: Invalid email instead of Error: Invalid email. In the catch, instanceof distinguishes this expected failure from an unexpected one, and cause retains the deeper reason for the log without flattening it into a string.
Common mistake
Throwing a string (throw 'failed') or a bare object, which produces no stack and forces string comparison in catch. A subtler mistake is calling Error.captureStackTrace directly: it is a V8 extension, so portable code should rely on the standard stack that super already provides.
Verify the behavior
Assert instanceof for both the subclass and Error. Confirm name shows up in a serialized log line. Throw a subclass with a cause and check both error.message and error.cause.message. Finally, verify that a catch for the base class still catches the subclass, so broad handling and specific handling can coexist.
Interview exercise
Should an HTTP layer throw Error or a custom HttpError, and where should the status code live?
Answer and reasoning
Use a custom HttpError that carries a status field and a short, safe message. The status is data the transport layer needs, so it belongs on the error rather than being parsed out of the message string. Callers can then branch on instanceof HttpError and read status directly, while a top-level handler maps any unknown error to a generic 500 without leaking internal detail.
Follow-up discussion
Does extending Error break instanceof across realms? It can: an error created in an iframe has a different Error constructor, so prefer structural checks such as a name or a brand field when errors cross realms or are serialized. Are aggregate errors useful? AggregateError collects several failures, such as Promise.any rejections, and pairs well with a custom subclass when callers must inspect every cause.
Continue learning
See how promises surface these errors in JavaScript promise finally and read the MDN Error reference. Test yourself with the JavaScript MCQs.