How it works
The Error constructor works the same with or without new. It does three things: create the object from NewTarget's prototype with an [[ErrorData]] slot, define message as a non-enumerable own property only when a message is given, and run InstallErrorCause, which defines cause (also non-enumerable) only if the options argument is an object that has a "cause" property.
Because NewTarget is used, class HttpError extends Error gets HttpError.prototype as its prototype, and instanceof HttpError works. The [[ErrorData]] slot is what Error.isError and Object.prototype.toString look at:
const fake = Object.create(Error.prototype);
console.log(fake instanceof Error, Error.isError(fake));
// → true false
console.log(Object.prototype.toString.call(new Error('x')));
// → [object Error]
Error.prototype.toString builds the name: message text from the name and message properties. If name is empty it returns the message, and if message is empty it returns the name.
The spec defines no stack. Every major engine adds one, with a different format. V8 collects 10 frames by default (Error.stackTraceLimit) when the error is created. The Error Stack Accessor proposal (Stage 3 in the TC39 proposals list) would standardize only that the stack property exists as an accessor on Error.prototype, not the format.
Edge cases
AggregateError(errors, message, options) takes an iterable of errors first and stores them as an errors array. Promise.any uses it:
try {
await Promise.any([Promise.reject(new Error('a')), Promise.reject(new TypeError('b'))]);
} catch (error) {
console.log(error.constructor.name, error.errors.map(String));
}
// → AggregateError [ 'Error: a', 'TypeError: b' ]
The native error types are TypeError, RangeError, ReferenceError, SyntaxError, URIError, and EvalError (kept for compatibility, unused by the spec). They are subclasses of Error with their own name. Extend one of them only when callers are meant to treat your error as that kind, for example a RangeError for out-of-range input.
Real-world scenario
A repository layer catches a driver error and throws a domain error with the driver error as cause. The top-level logger then walks the chain:
function describeChain(error) {
const lines = [];
for (let e = error; Error.isError(e); e = e.cause) lines.push(`${e.name}: ${e.message}`);
return lines;
}
const root = new TypeError('socket closed');
const top = new Error('load profile failed', {
cause: new Error('fetch failed', { cause: root }),
});
console.log(describeChain(top));
// → [
// → 'Error: load profile failed',
// → 'Error: fetch failed',
// → 'TypeError: socket closed'
// → ]
The loop stops at the first non-error cause, which also guards against a cause that is a string or undefined. Node's util.inspect (and so console.log) prints cause as [cause] under the error.
Trade-offs
A class hierarchy (NotFoundError, ValidationError, ...) makes instanceof branching easy, but it is fragile when the same module is bundled twice or errors cross postMessage. A single class with a string code (error.code === 'ENOENT', like Node's own errors) survives both. Pick one convention for the codebase. Keep the hierarchy shallow either way.