Creating Custom Errors in JS: Complete Tutorial

Build your own error types by extending the Error class. Learn how custom errors make your code more readable, easier to debug, and safer to handle with instanceof checks.

7 min read

A custom error is an error type you create by extending the built-in Error class. Instead of throwing new Error("something failed") everywhere, you define named error types like ValidationError, NetworkError, or TimeoutError that carry meaningful properties and can be identified with instanceof.

The benefit is precision. A catch block that receives a ValidationError knows exactly what happened and can respond appropriately, instead of guessing by parsing a message string. This matters most once an application has several failure modes that need different handling, such as a validation problem that should highlight a form field versus a network problem that should trigger a retry.

javascriptjavascript
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}
 
try {
  throw new ValidationError("Email is required", "email");
} catch (error) {
  console.log(`Field "${error.field}" is invalid: ${error.message}`);
  // Field "email" is invalid: Email is required
}

The class extends Error and adds a field property that a plain error could never carry. Checking error.field lets the catch block highlight the exact form input that failed, something a generic message string cannot do on its own.

Extending the Error class

The minimum custom error needs three things: a class that extends Error, a call to super(message) as the first line of the constructor, and setting this.name.

javascriptjavascript
class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseError";
  }
}
 
const error = new DatabaseError("Connection refused on port 5432");
console.log(error.name);                     // "DatabaseError"
console.log(error instanceof DatabaseError);  // true
console.log(error instanceof Error);          // true

Calling super(message) is critical. The Error base constructor needs the message to populate error.message and generate the stack trace. Setting this.name to the class name is a convention rather than a requirement, but it helps whenever you log error.name or serialize errors for reporting.

Adding custom properties

Raw error messages are not enough for real applications. Add properties that your error handling code can act on programmatically instead of parsing text:

javascriptjavascript
class ApiError extends Error {
  constructor(message, statusCode, endpoint) {
    super(message);
    this.name = "ApiError";
    this.statusCode = statusCode;
    this.endpoint = endpoint;
  }
}

ApiError adds two fields a plain error cannot carry: statusCode and endpoint. A fetch helper can throw this type instead of a generic error whenever a response comes back with a failing status:

javascriptjavascript
async function fetchData(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new ApiError(`Request failed with status ${response.status}`, response.status, url);
  }
  return response.json();
}

The statusCode field lets a caller branch on the HTTP status without parsing the message, and the endpoint field tells you which URL failed:

javascriptjavascript
try {
  await fetchData("/api/users");
} catch (error) {
  if (error instanceof ApiError && error.statusCode === 404) {
    console.log("Resource not found at:", error.endpoint);
  }
}

Checking error instanceof ApiError first is what makes reading statusCode safe here. A plain network failure would not be an ApiError and would not have that property at all.

Chaining errors with the cause property

When you catch an error and wrap it in a new one, pass the original as the cause option. This preserves the full error chain for debugging instead of losing the original failure:

javascriptjavascript
class ConfigError extends Error {
  constructor(message, cause) {
    super(message, { cause });
    this.name = "ConfigError";
  }
}

The second argument to super() accepts an options object with a cause key. Catching a lower-level failure and wrapping it keeps both messages available on the new error:

javascriptjavascript
try {
  JSON.parse("invalid");
} catch (parseError) {
  const configError = new ConfigError("Failed to parse config file", parseError);
  console.log(configError.message);       // "Failed to parse config file"
  console.log(configError.cause.message); // "Unexpected token 'i', ..."
}

The cause property is a standard part of the Error API since ES2022, supported in every modern browser and in Node.js 16.9 and later. When you send errors to a monitoring service, cause gives you the full story of what actually went wrong underneath the top-level message.

A real-world error hierarchy

In a larger application, you will want several custom error types that share common properties. Use a base class that every specific error extends:

javascriptjavascript
class AppError extends Error {
  constructor(message, code, cause) {
    super(message, { cause });
    this.name = "AppError";
    this.code = code;
  }
}

Every specific error type extends AppError instead of Error directly, so they all pick up the code and cause fields for free:

javascriptjavascript
class NotFoundError extends AppError {
  constructor(resource, id, cause) {
    super(`Resource ${resource} with id ${id} not found`, "NOT_FOUND", cause);
    this.name = "NotFoundError";
    this.resource = resource;
  }
}

PermissionError and RateLimitError follow the same shape as NotFoundError, each passing its own message and code up to AppError while adding the one or two fields specific to that failure:

javascriptjavascript
class PermissionError extends AppError {
  constructor(action, cause) {
    super(`Permission denied: ${action}`, "PERMISSION_DENIED", cause);
    this.name = "PermissionError";
  }
}
 
class RateLimitError extends AppError {
  constructor(retryAfterSeconds, cause) {
    super(`Rate limit exceeded, retry after ${retryAfterSeconds}s`, "RATE_LIMITED", cause);
    this.name = "RateLimitError";
    this.retryAfterSeconds = retryAfterSeconds;
  }
}
Custom error class hierarchy

Every custom error in this hierarchy is still an instance of Error, so a generic fallback handler works without changes anywhere in the chain. The code field also gives you a stable identifier you can log, search for, or match against in an alerting rule, since messages tend to change wording over time while a code like "NOT_FOUND" stays constant. Now the error handling itself gets clean and specific instead of relying on string matching:

javascriptjavascript
try {
  await performAction();
} catch (error) {
  if (error instanceof RateLimitError) {
    await delay(error.retryAfterSeconds * 1000);
    return retry();
  }
  if (error instanceof NotFoundError) {
    redirectTo("/404");
    return;
  }
  reportError(error);
}

This pattern replaces brittle string matching, such as checking whether a message includes the text "404", with type checks that stay correct even if the wording of a message changes later.

Common mistakes

Forgetting to call super() is the most common mistake. In a derived class, the constructor cannot read or write this until super() has run, so skipping it does not silently produce a broken error object, it throws immediately:

javascriptjavascript
class BadError extends Error {
  constructor(message) {
    this.name = "BadError"; // ReferenceError: must call super first
    this.details = message;
  }
}
 
new BadError("fail"); // throws before the error is ever created

Setting this before calling super() throws the same reference error, just in the opposite order. The fix is identical either way: call super(message) as the very first line of the constructor, before touching this at all.

Not setting this.name is a quieter mistake. The error will work, but error.name will read as "Error" instead of your custom type name, which is misleading for anyone reading logs or a monitoring dashboard:

javascriptjavascript
class UnnamedError extends Error {
  constructor(message) {
    super(message);
  }
}
 
console.log(new UnnamedError("oops").name); // "Error", not "UnnamedError"

Overcomplicating the hierarchy is the last common mistake. You do not need an error class for every possible failure.

Start with one or two custom types and add more only when you have a real need to distinguish them with instanceof in a catch block. If you never write that check for a type, you probably do not need the class.

Preserving stack traces in older environments

Modern JavaScript engines handle extends Error correctly out of the box. V8-based runtimes such as Chrome, Edge, and Node.js also expose a non-standard Error.captureStackTrace() method that trims the constructor itself out of the stack trace:

javascriptjavascript
class LegacyError extends Error {
  constructor(message) {
    super(message);
    this.name = "LegacyError";
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, LegacyError);
    }
  }
}

Safari's JavaScriptCore engine does not implement captureStackTrace, which is why the code checks if (Error.captureStackTrace) before calling it. For most applications in 2026, you can skip this entirely: just extend Error, call super(message), and set this.name.

For more on using custom errors with try...catch, see the try...catch guide. For the full list of built-in error types you can extend from, see the JavaScript error types guide.

Rune AI

Rune AI

Key Insights

  • Extend the Error class to create custom error types with meaningful names.
  • Use instanceof in catch blocks to handle each error type appropriately.
  • Add custom properties like error codes, field names, or HTTP statuses to make handling richer.
  • Pass the original error as cause to preserve the full error chain.
  • Always call super(message) first in the constructor, then set this.name.
RunePowered by Rune AI

Frequently Asked Questions

Why create custom errors instead of using new Error('message')?

Custom errors let you use instanceof to identify specific error types in catch blocks. Instead of parsing error messages, you can write `if (error instanceof ValidationError)` to handle validation failures separately from network errors.

Do custom error classes preserve the stack trace?

Yes. Extending Error automatically preserves the stack trace. In older environments, you may need to call `Error.captureStackTrace(this, CustomError)` inside the constructor, but modern engines handle this natively.

Can I add custom properties to my error class?

Yes. Add any properties you need in the constructor: error codes, field names, HTTP status codes, user-facing messages, or structured data. Just call `super(message)` first.

Conclusion

Custom error types turn a generic catch block into precise, readable error handling. By extending Error and adding domain-specific properties, you can distinguish between validation failures, network timeouts, and permission errors without parsing strings. Use the cause property to chain errors and preserve the full debugging context.