Rxova
Skip to content

Subclassing Error

ExtendedError’s constructor is about fifteen lines. Each one exists for a specific, reproducible failure.

constructor(message: string, options: ExtendedErrorOptions<Context> = {}) {
super(message, superOptions(options))
const constructedBy = new.target
Object.setPrototypeOf(this, constructedBy.prototype)
this.name = constructedBy.name
this.code = constructedBy.code
this.context = options.context
captureStack(this, constructedBy)
}

new.target is the class the new expression actually named. For new ConfigError(…) it is ConfigError, even though this code lives on the base class — which is what lets a subclass get its own name and its own code without restating either.

Compile a class down to ES5 — directly, or through a bundler that targets an older baseline — and super() returns a fresh Error object rather than initialising this. The prototype chain is lost with it, so instanceof ConfigError is false.

Restoring the prototype costs one property write in the constructor and is unfixable at the call site, which is the trade that decides it. It is the single most common surprise when subclassing Error in TypeScript, and the one most likely to be discovered in production, since it only appears under a build configuration the tests may not use.

Error’s name comes from the prototype and stays 'Error' for a subclass that does not set it. Every log line, every toString(), every serialized payload then says Error for a class you named carefully. Setting it from new.target.name means a subclass reports itself, and a class declared with defineError reports the name passed to the factory — the factory redefines the class’s own name for exactly that reason, since a class expression would otherwise be called Defined.

Declared static so it is written once per class rather than at every throw site, and copied onto the instance so it survives serialization: a plain object produced from the error has no class to read a static off.

A class that declares no code of its own gets its base’s, which is what you want for a leaf that callers distinguish by instanceof rather than by code.

const superOptions = (options) => ('cause' in options ? { cause: options.cause } : undefined)

super(message, { cause: undefined }) still defines the property. 'cause' in error would then be true for an error that has none — and that is precisely the check a chain walker uses to decide where to stop. So the options object is forwarded only when the caller actually passed a cause.

Without it, the top frame of the stack is the constructor, and the line that actually threw is one frame down. Passing the constructor as the second argument omits its own frames, so the trace starts at the throw site.

It is V8-only — JavaScriptCore and SpiderMonkey have no such method — hence the typeof guard. On those engines the stack is whatever the engine produced, which is still correct, just one frame noisier.

message and stack are non-enumerable on Error, so JSON.stringify(new Error('x')) is {}. Defining toJSON as serializeError(this) is what makes an error usable with any logger that stringifies its input, without that logger knowing anything about this package.

Classes on a base that is not an ExtendedError

Section titled “Classes on a base that is not an ExtendedError”

defineError(name, { base: RangeError }) cannot extend ExtendedError as well — a class has one parent. So the factory builds an equivalent constructor on top of the given base: the same new.target reads, the same prototype restore, the same stack capture, the same toJSON.

The result carries every member an ExtendedError has, but instanceof ExtendedError and isExtendedError are false for it. That is the accurate answer rather than a convenient one: it is a RangeError that happens to carry the same fields.