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.

JavaScriptintermediate
12 min read

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

javascriptjavascript
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 site

The 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:

javascriptjavascript
// 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);   // true

Why 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:

javascriptjavascript
// 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:

typescripttypescript
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:

javascriptjavascript
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

javascriptjavascript
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:

javascriptjavascript
// 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:

javascriptjavascript
// 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); // true

Checking Error Relationships

CheckUse
err instanceof SpecificErrorSame realm, recommended
err.name === "SpecificError"Cross-realm safe (iframe, workers)
err.constructor === SpecificErrorExact class only (no subclasses)
err.code === "SPECIFIC_CODE"Code-based, serializable
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

Why must I call super() before accessing this?

JavaScript requires that a subclass's constructor calls `super()` before accessing `this`. This is because `this` is not initialized until the parent constructor runs. With `extends Error`, `super(message)` initializes the Error mechanics (message, stack). Accessing `this.name = ...` before `super()` triggers a ReferenceError.

Does Object.setPrototypeOf affect performance?

In V8 and SpiderMonkey, modifying an object's prototype after creation deoptimizes the object (it leaves the "hidden class" fast path). For error objects, this is acceptable โ€” exceptions are not in hot paths. For frequently created objects, avoid `setPrototypeOf`. Native ES6+ class compilation does not need this workaround.

Should I override toString() in custom errors?

Optionally. The default `Error.toString()` returns `"ErrorName: message"`. Overriding it to include extra context (like error code) can help in logs. `console.log(err.toString())` and template literals use `toString()`.

Can I extend built-in errors like TypeError?

Yes: `class StrictTypeError extends TypeError {}`. The same prototype fix applies in transpiled environments. Built-in Error subtypes (`TypeError`, `RangeError`, `SyntaxError`, etc.) all extend `Error` and can be extended themselves.

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.