Extending the JavaScript Error Class: Full Guide
Deep dive into extending JavaScript's Error class. Understand the inheritance pitfalls with Babel/TypeScript, fix prototype chain issues, build mixin-style errors, implement aggregate errors, and create production-ready base error classes.
Extending Error in JavaScript is straightforward in modern environments but has historically had subtle traps — particularly with Babel transpilation and TypeScript compilation targets that break the prototype chain. This guide covers the precise mechanics of Error extension, fixes for common environment-specific issues, advanced patterns like aggregate errors and error mixins, and a production-ready base class.
The Standard Extension
class CustomError extends Error {
constructor(message) {
super(message); // (1) Call Error constructor
this.name = this.constructor.name; // (2) Set name to class name
if (Error.captureStackTrace) { // (3) V8 stack optimization
Error.captureStackTrace(this, this.constructor);
}
}
}
const err = new CustomError("test");
console.log(err.name); // "CustomError"
console.log(err.message); // "test"
console.log(err instanceof Error); // true
console.log(err.stack); // Stack starting at the throw siteThe three lines in the constructor are the minimum boilerplate for a correct Error subclass.
The Babel/TypeScript Prototype Chain Bug
When transpiling ES6 classes to ES5 (older target in tsconfig.json, or Babel), extending built-ins like Error breaks instanceof in subclasses:
// This works in native ES6 but BREAKS with Babel ES5 output:
class MyError extends Error {}
const err = new MyError("oops");
console.log(err instanceof MyError); // false (!) — Babel bug
console.log(err instanceof Error); // trueWhy it happens: Babel transpiles class to function constructors. The built-in Error constructor does not properly set this to the subclass instance when called as Error.call(this, message) in ES5.
The fix: Manually restore the prototype after calling super:
// Safe for ES5 transpilation AND native ES6
class SafeCustomError extends Error {
constructor(message) {
super(message);
// Fix prototype chain for Babel/TypeScript ES5 output
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
}
}
// Now instanceof works correctly even in transpiled code:
const err = new SafeCustomError("test");
console.log(err instanceof SafeCustomError); // true (everywhere)The new.target.prototype approach is correct for subclasses too — each subclass's new.target points to its own prototype.
TypeScript-Specific Fix
In TypeScript, the same issue exists when target is ES5 in tsconfig.json:
class AppError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype); // Required for ES5 target
this.name = new.target.name;
}
}If you target ES2015 or newer in TypeScript, the fix is not needed (native class semantics are used).
Production-Ready Base Error Class
A complete base class that handles all the nuances:
class AppError extends Error {
/**
* @param {string} message - Human-readable error description
* @param {object} [options]
* @param {string} [options.code] - Machine-readable error code
* @param {Error} [options.cause] - Original error (ES2022 error chaining)
* @param {object} [options.context] - Extra diagnostic data
* @param {number} [options.statusCode] - HTTP status (for API errors)
*/
constructor(message, options = {}) {
super(message, { cause: options.cause });
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
this.code = options.code ?? this.constructor.name.toUpperCase();
this.context = options.context ?? {};
this.statusCode = options.statusCode ?? 500;
this.timestamp = new Date().toISOString();
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
statusCode: this.statusCode,
timestamp: this.timestamp,
context: this.context,
cause: this.cause
? (this.cause instanceof Error
? { name: this.cause.name, message: this.cause.message }
: this.cause)
: undefined,
};
}
toString() {
return `${this.name} [${this.code}]: ${this.message}`;
}
}Subclassing the Base
class ValidationError extends AppError {
constructor(message, violations = []) {
super(message, { statusCode: 400, code: "VALIDATION_FAILED" });
this.violations = violations;
}
toJSON() {
return { ...super.toJSON(), violations: this.violations };
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} '${id}' not found`, {
statusCode: 404,
code: "NOT_FOUND",
context: { resource, id },
});
}
}
class NetworkError extends AppError {
constructor(message, { statusCode = 503, cause } = {}) {
super(message, { statusCode, cause, code: "NETWORK_ERROR" });
}
}Aggregate Errors
An aggregate error groups multiple errors. JavaScript has a built-in AggregateError (from Promise.any), and you can build your own:
// Built-in AggregateError
const agg = new AggregateError(
[new Error("First"), new Error("Second")],
"Multiple operations failed"
);
console.log(agg.errors.length); // 2
console.log(agg instanceof Error); // true
// Custom aggregate for application domain
class BatchError extends AppError {
constructor(message, errors = []) {
super(message, { code: "BATCH_FAILED", statusCode: 500 });
this.errors = errors;
this.successCount = 0;
this.failureCount = errors.length;
}
addFailure(item, error) {
this.errors.push({ item, error });
this.failureCount++;
return this;
}
addSuccess() {
this.successCount++;
return this;
}
toJSON() {
return {
...super.toJSON(),
successCount: this.successCount,
failureCount: this.failureCount,
errors: this.errors.map(({ item, error }) => ({
item,
error: error instanceof AppError ? error.toJSON() : error.message,
})),
};
}
}Error Mixin Pattern
For orthogonal concerns (like HTTP status or retry behavior) that should work with any error type, mixins are useful:
// Mixin: adds HTTP-related properties
const HttpMixin = (Base) => class extends Base {
constructor(message, statusCode, options) {
super(message, options);
this.statusCode = statusCode;
this.isClientError = statusCode >= 400 && statusCode < 500;
this.isServerError = statusCode >= 500;
}
};
// Mixin: adds retry information
const RetryMixin = (Base) => class extends Base {
constructor(message, retryable = true, options) {
super(message, options);
this.retryable = retryable;
this.retryAfter = null;
}
};
// Compose mixins
class ApiError extends HttpMixin(RetryMixin(AppError)) {
constructor(message, statusCode) {
const retryable = statusCode >= 500 || statusCode === 429;
super(message, retryable);
this.statusCode = statusCode; // Override from HttpMixin
}
}
const err = new ApiError("Rate limited", 429);
console.log(err.retryable); // true
console.log(err.isClientError); // trueChecking Error Relationships
| Check | Use |
|---|---|
err instanceof SpecificError | Same realm, recommended |
err.name === "SpecificError" | Cross-realm safe (iframe, workers) |
err.constructor === SpecificError | Exact class only (no subclasses) |
err.code === "SPECIFIC_CODE" | Code-based, serializable |
Rune AI
Key Insights
- Always call super(message) first: Error initialization happens in the parent constructor; calling super first enables this before accessing this
- Object.setPrototypeOf fixes Babel/TS ES5: Without it, instanceof checks fail in transpiled code because the prototype chain is not properly set on the subclass instance
- this.name = this.constructor.name is dynamic: It automatically reflects the correct class name for every subclass without hardcoding strings that can become stale
- toJSON enables serialization: Error properties are non-enumerable and lost in JSON.stringify; a toJSON method makes errors safely serializable for logging and API responses
- Mixins compose orthogonal concerns: HTTP status, retry behavior, and other cross-cutting concerns can be added as mixins rather than linear inheritance chains
Frequently Asked Questions
Why must I call super() before accessing this?
Does Object.setPrototypeOf affect performance?
Should I override toString() in custom errors?
Can I extend built-in errors like TypeError?
Conclusion
Extending Error correctly requires three things in any environment: calling super(message), setting this.name = this.constructor.name, and adding Object.setPrototypeOf(this, new.target.prototype) for Babel/TypeScript ES5 compatibility. Beyond correctness, a good base class with toJSON(), optional properties like code and context, and error cause chaining provides everything needed for a robust error system. Combined with custom error hierarchies and advanced try/catch patterns, this forms a complete approach to application error management.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.