How to Extend Error in JavaScript

Aug 14, 2026·21 min read

A failed profile request came back as Could not load account. The useful part, a database timeout with its original stack and message, had been replaced by one friendly sentence.

Extend Error by calling super(message, options), then add a stable error code and structured details while preserving the original failure through cause.

How to Extend Error in JavaScript

The Modern Pattern for Extending Error

A custom error is an Error subclass that gives a failure a useful category and a predictable shape. It still has a human-readable message, but it can also carry a stable code, structured details, and the original cause.

Start with one reusable base class:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);

    this.name = new.target.name;
    this.code = options.code ?? 'APP_ERROR';
    this.details = options.details;
  }
}

const error = new AppError('Could not load account', {
  code: 'ACCOUNT_LOAD_FAILED',
  details: { accountId: 42 },
  cause: new Error('database timeout'),
});

console.log(error instanceof Error);
console.log(error instanceof AppError);
console.log(error.name);
console.log(error.code);
console.log(error.message);
console.log(error.cause.message);
true
true
AppError
ACCOUNT_LOAD_FAILED
Could not load account
database timeout

extends Error connects AppError.prototype to Error.prototype, so both instanceof checks pass. super(message, options) lets the standard constructor create the message and, when options contains cause, the cause property.

The extra fields have separate jobs. code gives callers a stable value for programmatic decisions, while details carries values such as an account ID or field name. The message remains text for a person.

one AppError instancestandard Error identitystack • catch tools • instanceofmessagewords for a personcodestable machine decisiondetailsstructured contextcauseoriginal failure
A custom error carries several kinds of information for different consumers.

Throwing a string or plain object loses this standard identity:

const plainFailure = { message: 'Could not load account' };

console.log(plainFailure instanceof Error);
console.log(typeof plainFailure.stack);
false
undefined

The object has a property named message, but that does not make it an error. Extending Error preserves the behavior expected by catch handlers, loggers, test tools, and APIs that recognize actual error objects. Custom errors, extending Error covers the basic inheritance pattern from the language side.

What extends Error Actually Inherits

A class instance finds methods by following its prototype chain. An AppError instance first looks at its own properties, then at AppError.prototype, then at Error.prototype, and finally at Object.prototype.

lookup starts at the instanceerror instancemessage • code • details • causeAppError.prototypecustom methodsError.prototypestandard Error behaviorObject.prototype
Property lookup climbs the prototype chain until it finds a match.

You can inspect that chain directly:

class AppError extends Error {}

const error = new AppError('Upload failed');

console.log(Object.getPrototypeOf(error) === AppError.prototype);
console.log(Object.getPrototypeOf(AppError.prototype) === Error.prototype);
console.log(error instanceof Error);
true
true
true

The extends clause creates the relationship, but the subclass constructor still has to run the parent constructor. In a derived constructor, super() must run before you read or write this.

constructing a subclassnewAppErrorsuper()builds Errorstatethisavailablebefore this point: no instance access
The parent constructor opens the gate to the new instance.

Passing a message gives the instance a non-enumerable own message property. Passing an options object for which cause is present—including through its prototype chain—gives the instance a non-enumerable own cause property too.

The subclass sets name explicitly because the inherited name is otherwise Error:

class UnnamedError extends Error {}

class NamedError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NamedError';
  }
}

console.log(new UnnamedError('failed').name);
console.log(new NamedError('failed').name);
Error
NamedError

Using new.target.name in the base class keeps the name aligned with the concrete subclass. A ValidationError constructed through AppError therefore reports ValidationError without repeating the assignment in every constructor.

The stack property is different. Its format and behavior are implementation-dependent, and it must not become part of an application contract. Error.captureStackTrace() is also non-standard. Where it exists, its optional constructor argument can omit that constructor and the frames above it from the captured trace.

Error.captureStackTrace() is an optional stack refinement, not part of making an Error subclass work.

If you use it, guard the call:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);
    this.name = new.target.name;

    if (typeof Error.captureStackTrace === 'function') {
      Error.captureStackTrace(this, new.target);
    }
  }
}

const error = new AppError('Upload failed');

console.log(error instanceof Error);
console.log(typeof error.stack === 'string');
true
true

The boolean checks only that this runtime supplies a string stack. It does not depend on a particular stack format.

Design Error Classes That Callers Can Use

A useful error design separates four values that often get mixed together:

  • Class identifies a broad category such as validation or missing data.
  • Code gives program logic a stable identifier such as VALIDATION_FAILED.
  • Message explains the failure to a person.
  • Details hold structured context such as a field name, resource type, or ID.

The distinction matters because callers use each value differently. A catch handler can recognize a broad category with instanceof, switch on a stable code, display or log the message, and inspect details without parsing prose.

caughterrorclasscatch a categorycodechoose a branchmessageinform a persondetailsuse exact context
Each part of an error serves a different reader or task.

Here is one small hierarchy that keeps those jobs separate:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);
    this.name = new.target.name;
    this.code = options.code ?? 'APP_ERROR';
    this.details = options.details;
  }
}

class ValidationError extends AppError {
  constructor(message, options = {}) {
    super(message, {
      ...options,
      code: 'VALIDATION_FAILED',
    });
  }
}

class NotFoundError extends AppError {
  constructor(resource, id, options = {}) {
    super(`${resource} was not found`, {
      ...options,
      code: 'NOT_FOUND',
      details: { resource, id, ...options.details },
    });
  }
}

const invalid = new ValidationError('Email address is invalid', {
  details: { field: 'email' },
});

const missing = new NotFoundError('Account', 42);

console.log(invalid instanceof AppError);
console.log(invalid.code, invalid.details.field);
console.log(missing.message);
console.log(missing.code, missing.details.resource, missing.details.id);
true
VALIDATION_FAILED email
Account was not found
NOT_FOUND Account 42

ValidationError and NotFoundError are classes because they describe broad failure categories. Their codes are fixed machine-readable values. Their messages can change when wording improves without forcing callers to change their branches.

Do not make callers parse message to discover what happened. Punctuation, IDs, translations, and rewritten wording all make message matching brittle. A code gives the decision a dedicated field.

Details should be data, not another message in disguise. { field: 'email' } can drive a form response directly. The string Email failed validation in the email field has to be interpreted again.

A hierarchy can grow too far. Create a subclass when callers need to catch a broad category or the category has behavior of its own. Use codes when several specific failures share that category. Coding style takes the same approach to names and structure across ordinary JavaScript, while JavaScript Fundamentals develops the underlying class and error-handling concepts as part of the full course.

Wrap Failures Without Losing the Cause

Wrapping means adding higher-level context around one original failure. The outer error explains what your operation could not do, and cause retains the value that explains why.

Say an account loader receives a lower-level storage failure:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);
    this.name = new.target.name;
    this.code = options.code ?? 'APP_ERROR';
    this.details = options.details;
  }
}

function readAccountRecord() {
  throw new Error('connection timed out');
}

function loadAccount(accountId) {
  try {
    return readAccountRecord(accountId);
  } catch (cause) {
    throw new AppError('Could not load account', {
      code: 'ACCOUNT_LOAD_FAILED',
      details: { accountId },
      cause,
    });
  }
}

try {
  loadAccount(42);
} catch (error) {
  console.log(error.code);
  console.log(error.message);
  console.log(error.cause.message);
}
ACCOUNT_LOAD_FAILED
Could not load account
connection timed out

The outer message describes the failed application operation. The cause retains the lower-level message and its error identity. Nothing has to concatenate messages or extract meaning from a combined string.

Replacing the caught error with throw new Error('Could not load account') discards that connection. Appending cause.message to a new message preserves a piece of text, but not the original value or its other diagnostic information.

A cause can be any JavaScript value. This is valid:

const error = new Error('Import failed', {
  cause: 'remote service refused the file',
});

console.log(error.message);
console.log(typeof error.cause);
console.log(error.cause);
Import failed
string
remote service refused the file

Code that reads error.cause.message without checking the cause can fail while reporting the first failure.

Use cause for one underlying failing site. Use AggregateError when one operation produces multiple unrelated failures that need to be reported together. Error Cause and AggregateError develops both shapes, and Error handling with promises applies them to rejected asynchronous work.

Catch and Identify Custom Errors Safely

A catch block receives whatever was thrown. JavaScript permits errors, strings, numbers, objects, and other values, so the caught value is not guaranteed to have message, stack, or code.

Handle errors you recognize, then rethrow everything else:

caught valueinstanceofValidationError?yeshandle herecontinue safelynothrow errorsame value leaves
Only recognized errors stop in the local catch handler.
class AppError extends Error {}

class ValidationError extends AppError {}

function saveProfile(mode) {
  if (mode === 'invalid') {
    throw new ValidationError('Display name is required');
  }

  throw 'storage unavailable';
}

let unknown;

try {
  for (const mode of ['invalid', 'offline']) {
    try {
      saveProfile(mode);
    } catch (error) {
      if (error instanceof ValidationError) {
        console.log(`handled: ${error.message}`);
        continue;
      }

      unknown = error;
      throw error;
    }
  }
} catch (error) {
  console.log(`rethrown unchanged: ${error === unknown}`);
}
handled: Display name is required
rethrown unchanged: true

In production code, the unknown branch should normally use throw error after any necessary cleanup. Throwing the same value preserves it. throw new Error(String(error)) replaces its identity and loses an existing cause or stack.

instanceof is strongest inside one connected runtime and one copy of the class. It depends on prototype identity, so a value from another window or frame can fail a check against the current realm’s constructor. Separate copies of the same package can produce the same problem because each copy creates its own constructor and prototype.

current windowErrorprototype Ainstanceof asks:linked to A?iframeErrorprototype Bforeign erroris linked to Bsame name • different identity • false
Matching names do not guarantee matching prototypes across realms.

Workers and serialization add another boundary. Error objects support structured cloning, but code must not assume that every custom subclass prototype and custom field arrives unchanged in every target runtime. Send an explicit data shape when the receiver needs a contract.

At such a boundary, check validated fields such as code and name rather than trusting instanceof alone. Do not accept an untrusted object’s claimed code without validating the rest of its shape.

Log and Serialize Errors Deliberately

Standard error properties do not behave like ordinary enumerable data fields. message and cause are non-enumerable, so JSON.stringify() does not include them by default.

The difference is visible with one error:

const error = new Error('Could not save profile', {
  cause: new Error('disk unavailable'),
});

error.code = 'PROFILE_SAVE_FAILED';

console.log(JSON.stringify(error));
console.log(Object.keys(error).join(', '));
{"code":"PROFILE_SAVE_FAILED"}
code

The code appears because the assignment created an enumerable property. The standard message and cause do not.

Error objectcodeenumerablemessagenon-enumerablecausenon-enumerableJSONfilterJSON{ code }invisible does not mean absentmessage and cause still exist on the Error
Default JSON serialization sees only enumerable own properties.

Create the log shape explicitly:

const MAX_CAUSE_DEPTH = 4;
const MAX_VALUE_DEPTH = 3;
const MAX_ITEMS = 20;
const MAX_STRING_LENGTH = 1_000;
const ALLOWED_DETAIL_FIELDS = ['profileId', 'field'];

function truncate(value) {
  return value.length <= MAX_STRING_LENGTH
    ? value
    : `${value.slice(0, MAX_STRING_LENGTH)}…`;
}

function readDataProperty(value, key) {
  let current = value;

  for (let depth = 0; current != null && depth < 16; depth += 1) {
    try {
      const descriptor = Object.getOwnPropertyDescriptor(current, key);

      if (descriptor) {
        return Object.hasOwn(descriptor, 'value')
          ? descriptor.value
          : undefined;
      }

      current = Object.getPrototypeOf(current);
    } catch {
      return undefined;
    }
  }

  return undefined;
}

function hasErrorBrand(value) {
  if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
    return false;
  }

  if (typeof Error.isError === 'function') {
    return Error.isError(value);
  }

  return (
    typeof readDataProperty(value, 'name') === 'string' &&
    typeof readDataProperty(value, 'message') === 'string'
  );
}

function normalizeValue(value, depth, seen) {
  if (typeof value === 'string') return truncate(value);
  if (typeof value === 'number') {
    return Number.isFinite(value) ? value : String(value);
  }
  if (typeof value === 'boolean' || value === null) return value;
  if (typeof value === 'undefined') return '[undefined]';
  if (typeof value === 'function') return '[Function]';
  if (depth >= MAX_VALUE_DEPTH) return '[Truncated]';

  seen.add(value);

  if (Array.isArray(value)) {
    const items = value
      .slice(0, MAX_ITEMS)
      .map((item) => normalizeValue(item, depth + 1, seen));

    if (value.length > MAX_ITEMS) items.push('[Truncated]');
    return items;
  }

  const result = {};

  for (const key of ALLOWED_DETAIL_FIELDS) {
    const item = readDataProperty(value, key);
    if (item !== undefined) {
      result[key] = normalizeValue(item, depth + 1, seen);
    }
  }

  return result;
}

function serializeErrorShape(error, causeDepth, seen) {
  if (seen.has(error)) return '[Circular error]';
  seen.add(error);

  const name = readDataProperty(error, 'name');
  const code = readDataProperty(error, 'code');
  const message = readDataProperty(error, 'message');
  const details = readDataProperty(error, 'details');
  const cause = readDataProperty(error, 'cause');

  return {
    name: typeof name === 'string' ? truncate(name) : 'Error',
    code:
      typeof code === 'string' || typeof code === 'number'
        ? normalizeValue(code, 0, seen)
        : undefined,
    message: typeof message === 'string' ? truncate(message) : '',
    details:
      details === undefined ? undefined : normalizeValue(details, 0, seen),
    cause:
      cause === undefined
        ? undefined
        : serializeCause(cause, causeDepth + 1, seen),
  };
}

function serializeCause(cause, depth, seen) {
  if (depth > MAX_CAUSE_DEPTH) return '[Cause chain truncated]';
  if (hasErrorBrand(cause)) return serializeErrorShape(cause, depth, seen);
  return normalizeValue(cause, 0, seen);
}

function serializeError(error) {
  const seen = new WeakSet();

  if (!hasErrorBrand(error)) {
    return { thrown: normalizeValue(error, 0, seen) };
  }

  return serializeErrorShape(error, 0, seen);
}

const error = new Error('Could not save profile', {
  cause: new Error('disk unavailable'),
});

error.name = 'AppError';
error.code = 'PROFILE_SAVE_FAILED';
error.details = { profileId: 17 };

console.log(JSON.stringify(serializeError(error)));
{"name":"AppError","code":"PROFILE_SAVE_FAILED","message":"Could not save profile","details":{"profileId":17},"cause":{"name":"Error","message":"disk unavailable"}}

A toJSON() method can return the same shape from your own error class. A separate serializer is often useful because it keeps logging policy outside the error and can handle unknown thrown values.

A log serializer is also a security boundary.

Details, causes, file paths, tokens, request bodies, and stacks can contain secrets. Select allowed fields instead of copying the whole error. Include a stack only in a protected diagnostic destination, and never make its text part of a public API response.

This serializer limits circular values, deeply nested cause chains, collection sizes. Its details allowlist is logging policy, not a responsibility to hide inside the error message; replace the example fields with the metadata your destination permits.

Transpilation Pitfalls and Tests

Modern runtimes support built-in subclassing, but code transpiled to pre-ES2015 output can lose the intended prototype when extending built-ins such as Error. The visible symptoms are failed subclass instanceof checks and missing subclass methods.

broken older outputafter repairinstanceError.prototypeLegacyErrorprototype missedinstanceLegacyError.prototypeError.prototypeObject.setPrototypeOf reconnects the missing link
Prototype repair restores the inheritance path that older output may break.

When that older output is required, TypeScript documents a manual repair after super():

class LegacyError extends Error {
  constructor(message) {
    super(message);
    Object.setPrototypeOf(this, LegacyError.prototype);
  }
}

Apply the repair to each subclass that needs it. Keep this compatibility code out of the modern pattern unless the compilation target and supported runtimes require it. TypeScript for JavaScript Developers covers the wider boundary between JavaScript behavior and emitted code.

Finish with tests that check behavior instead of a particular stack format:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, options);
    this.name = new.target.name;
    this.code = options.code ?? 'APP_ERROR';
    this.details = options.details;
  }

  toJSON() {
    return serializeError(this);
  }
}

class ValidationError extends AppError {}

const cause = new Error('parser stopped');
const error = new ValidationError('Input is invalid', {
  code: 'VALIDATION_FAILED',
  details: { field: 'email' },
  cause,
});

let crossRealmError;

if (typeof document !== 'undefined') {
  const frame = document.createElement('iframe');
  frame.hidden = true;
  document.documentElement.append(frame);
  crossRealmError = new frame.contentWindow.Error('foreign failure');
  frame.remove();
}

const checks = [
  error instanceof Error,
  error instanceof AppError,
  error instanceof ValidationError,
  error.cause === cause,
  error.details.field === 'email',
  typeof error.stack === 'string',
  crossRealmError === undefined ||
    serializeError(crossRealmError).message === 'foreign failure',
  JSON.stringify(error) ===
    '{"name":"ValidationError","code":"VALIDATION_FAILED","message":"Input is invalid","details":{"field":"email"},"cause":{"name":"Error","message":"parser stopped"}}',
];

let unknownWasRethrown = false;

try {
  try {
    throw 'network stopped';
  } catch (value) {
    if (!(value instanceof AppError)) throw value;
  }
} catch (value) {
  unknownWasRethrown = value === 'network stopped';
}

if (!checks.every(Boolean) || !unknownWasRethrown) {
  throw new Error('custom error tests failed');
}

console.log(`${checks.length + 1} tests passed`);

The stack test checks only that a diagnostic string exists. The rest verifies prototype identity, the inheritance hierarchy, cause preservation, metadata, JSON output, and rethrowing a value that is not an Error.

Frequently asked questions

How do you extend Error in JavaScript?
Create a class with extends Error, call super(message, options) before using this, and set a stable name and code. Passing the options object to super preserves the standard cause property.
Why must a custom error call super()?
A derived class cannot use this before its parent constructor runs. Calling super(message, options) lets Error initialize the message and optional cause before the subclass adds its own fields.
Why does JSON.stringify return an incomplete Error object?
JSON.stringify visits enumerable own properties, while standard properties such as message and cause are non-enumerable. Add an explicit serializer or toJSON method when those values must appear in logs.
Why can instanceof fail for a custom Error?
instanceof depends on prototype identity. Errors from another window, frame, duplicated package, or serialization boundary may not share the constructor used by the current code, even when their fields look identical.