Back

How to Create Custom Errors in JavaScript

How to Create Custom Errors in JavaScript

Catching an error is straightforward. Knowing which error you caught—and why—is where things get messy. When your catch block handles a database timeout the same way it handles invalid user input, debugging becomes guesswork.

Custom JavaScript errors solve this. They let you create structured, identifiable error types that carry meaningful context through your application. This guide covers the modern approach using ES2022+ features, including Error.cause for error chaining and JavaScript error classes that work reliably across current browsers and runtimes.

Key Takeaways

  • Extend the native Error class using class ... extends Error syntax for clean, maintainable custom errors
  • Pass both message and options to super() to preserve stack traces and enable error chaining
  • Use Error.cause (ES2022+) to wrap and preserve original errors from external sources
  • Add structured fields like statusCode or field instead of encoding context in message strings
  • Keep error hierarchies shallow—a base error with a few specialized subtypes covers most needs

The Standard Pattern: Extending Error

Modern error handling in JavaScript starts with class syntax. Forget the legacy prototype manipulation—class ... extends Error is the baseline approach:

class ValidationError extends Error {
  constructor(message, options) {
    super(message, options)
    this.name = 'ValidationError'
  }
}

The super(message, options) call is critical. It passes both the message and an options object to the parent Error constructor, which handles stack trace capture automatically.

Setting this.name ensures your error identifies itself correctly in stack traces and logging. Without it, your custom error displays as a generic “Error.”

Adding Structured Fields

Plain message strings are fragile. Parsing “Invalid email: user@” to extract context is error-prone. Instead, add structured fields:

class HttpError extends Error {
  constructor(message, statusCode, options) {
    super(message, options)
    this.name = 'HttpError'
    this.statusCode = statusCode
  }
}

throw new HttpError('Resource not found', 404)

Now your error handler can check error.statusCode directly rather than parsing strings. This pattern scales cleanly—add details, code, or any domain-specific fields your application needs.

Error Chaining with Error.cause

When wrapping errors from external sources—databases, APIs, libraries—you need to preserve the original error. The cause option, introduced in ES2022, handles this:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) {
      throw new HttpError('User fetch failed', response.status)
    }
    return response.json()
  } catch (error) {
    throw new HttpError('Unable to load user', 500, { cause: error })
  }
}

The original error remains accessible via error.cause, preserving the full error chain for debugging. This is supported across modern browsers and Node.js—no polyfills needed.

Building Small Error Hierarchies

JavaScript error hierarchies work best when kept shallow. A base application error with a few specialized subtypes covers most needs:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message, { cause: options.cause })
    this.name = this.constructor.name
    this.statusCode = options.statusCode ?? 500
  }
}

class NotFoundError extends AppError {
  constructor(message, options = {}) {
    super(message, { ...options, statusCode: 404 })
  }
}

class ValidationError extends AppError {
  constructor(message, field, options = {}) {
    super(message, { ...options, statusCode: 400 })
    this.field = field
  }
}

Using this.constructor.name automatically sets the error name from the class, reducing boilerplate in subclasses.

Discriminating Errors in Async Flows

Custom errors shine in async code where you need to handle different failure modes:

try {
  const user = await fetchUser(userId)
} catch (error) {
  if (error instanceof NotFoundError) {
    return { status: 404, body: { message: 'User not found' } }
  }
  if (error instanceof ValidationError) {
    return { status: 400, body: { message: error.message, field: error.field } }
  }
  // Unexpected error—log and return generic response
  console.error('Unexpected error:', error.cause ?? error)
  return { status: 500, body: { message: 'Internal error' } }
}

The instanceof check works correctly through the inheritance chain. You can also discriminate by checking custom fields or error.name when dealing with serialized errors.

For scenarios involving multiple simultaneous failures, AggregateError provides a standard way to bundle errors together—useful for parallel operations where several can fail independently.

TypeScript Consideration

If you’re using TypeScript, set target: "ES2022" or higher, or include "ES2022" in your lib array within tsconfig.json. This ensures proper typing for the cause property in the ErrorOptions interface.

Conclusion

Custom JavaScript errors built with class ... extends Error give you structured, identifiable exceptions that carry context through your application. Pass message and options to super(), use cause for error chaining, and add domain-specific fields instead of encoding information in message strings. Keep hierarchies shallow, and your error handling becomes predictable and debuggable.

FAQs

Custom errors let you distinguish between different failure types programmatically. Instead of parsing error messages to determine what went wrong, you can use instanceof checks or inspect custom properties like statusCode or field. This makes error handling more reliable and your code easier to debug and maintain.

Error.cause was introduced in ES2022 and is supported in all modern browsers and Node.js 16.9+. For older environments, the cause option is simply ignored—your code will still work, but the cause property will be undefined. Consider a polyfill only if you must support legacy runtimes.

Keep it shallow—typically two levels at most. A single base application error class with a handful of specialized subtypes covers most use cases. Deep hierarchies add complexity without proportional benefit and make refactoring harder as your application evolves.

Yes. When multiple operations can fail independently, wrap individual failures in custom errors, then bundle them using AggregateError. Each error in the errors array retains its type, so you can still use instanceof checks when processing the results.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay