JavaScript Custom Errors

A custom error is a class you define that extends JavaScript’s built-in Error, so you can throw objects that carry your own name, extra data, and behavior while still working with every tool built for standard errors — try/catch, instanceof, stack traces, and error-logging libraries. Instead of throwing a plain string or a generic Error with a vague message, custom errors let calling code distinguish ‘the network failed’ from ‘the input was invalid’ from ‘the user isn’t allowed to do this’, and react to each differently.

Overview / How It Works

Every error thrown in JavaScript is, at minimum, an object with a message, a name, and a stack trace — that is the contract the Error constructor sets up. When you write class MyError extends Error, you are creating a subclass: MyError.prototype sits below Error.prototype in the prototype chain, so every instance of MyError is also, genuinely, an instance of Error. Inside the constructor you must call super(message) before you touch this, because in a derived class the engine does not allocate this until the parent constructor — Error‘s — has run and initialized it. That single call to super(message) is what sets the inherited message property and generates the stack property.

Why subclass instead of just doing const err = new Error('bad input'); err.field = 'email';? Two reasons. First, discoverability and type-safety: error instanceof ValidationError is far more reliable in a catch block than checking whether err.field is defined or parsing err.message. Second, reuse: a class bundles the shape of the error together with its construction logic, so every ValidationError anywhere in a codebase is guaranteed to have the same fields, created the same way. This becomes essential once an application needs to distinguish more than one or two kinds of failure — invalid input, missing resource, permission denied, network timeout — each deserving its own handling path.

Under the hood, class Foo extends Error {} is sugar over prototype-based inheritance: Foo.prototype‘s internal prototype is Error.prototype, and Foo.prototype.constructor is Foo. When new Foo('msg') runs, the engine creates an object whose internal prototype is Foo.prototype, then runs Foo‘s constructor; the super(message) call inside it runs Error‘s constructor with this already bound to the new object, setting this.message and capturing this.stack. Because assignments like this.name = ... happen after super() returns, custom properties become ordinary own properties of the instance — enumerable and visible to Object.keys(), unlike message and stack, which are non-enumerable on the base Error.

Syntax

class CustomError extends Error {
  constructor(message, extraInfo) {
    super(message);            // sets this.message and this.stack
    this.name = 'CustomError'; // shows up in toString() and stack traces
    this.extraInfo = extraInfo; // any extra data you want to carry
  }
}

// throw new CustomError('Something specific went wrong', { code: 42 });
  • extends Error — puts CustomError.prototype in front of Error.prototype in the chain, so instanceof Error still works.
  • constructor(message, ...) — accept whatever arguments make sense; message is conventional but not required.
  • super(message) — must run before this is used; delegates to Error‘s own constructor.
  • this.name — override the inherited 'Error' name so logs and toString() identify the specific error type.
  • extra properties — attach structured data instead of encoding it into the message string.

Examples

Example 1: A basic custom error

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

function validateAge(age) {
  if (age < 0) {
    throw new ValidationError('Age cannot be negative');
  }
  return age;
}

try {
  validateAge(-5);
} catch (error) {
  console.log(error.name);
  console.log(error.message);
  console.log(error instanceof ValidationError);
  console.log(error instanceof Error);
}

Output:

ValidationError
Age cannot be negative
true
true

ValidationError only overrides name; everything else — message, the prototype chain, stack — comes free from Error. Because the chain includes Error.prototype, the caught object is instanceof both ValidationError and Error, so generic error-handling code and specific error-handling code can both recognize it.

Example 2: Attaching structured data

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

function fetchResource(url, statusCode) {
  if (statusCode >= 400) {
    throw new HttpError(`Request failed with status ${statusCode}`, statusCode, url);
  }
  return { url, statusCode };
}

try {
  fetchResource('/api/users/42', 404);
} catch (error) {
  if (error instanceof HttpError) {
    console.log(`${error.name}: ${error.message}`);
    console.log(`Status: ${error.statusCode}, URL: ${error.url}`);
  }
}

Output:

HttpError: Request failed with status 404
Status: 404, URL: /api/users/42

statusCode and url are real properties on the error object, not text buried inside the message. Calling code can branch on error.statusCode >= 500 to decide whether a retry makes sense, or log error.url separately from the human-readable message — something that is awkward when every detail is squeezed into one string.

Example 3: An error hierarchy

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

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} was not found`);
    this.resource = resource;
  }
}

class PermissionError extends AppError {
  constructor(action) {
    super(`You do not have permission to ${action}`);
    this.action = action;
  }
}

function handle(error) {
  if (error instanceof NotFoundError) {
    console.log(`404 handler: ${error.message}`);
  } else if (error instanceof PermissionError) {
    console.log(`403 handler: ${error.message}`);
  } else if (error instanceof AppError) {
    console.log(`Generic app error: ${error.message}`);
  } else {
    throw error;
  }
}

handle(new NotFoundError('User'));
handle(new PermissionError('delete this post'));

Output:

404 handler: User was not found
403 handler: You do not have permission to delete this post

AppError acts as a shared base class; NotFoundError and PermissionError extend it and add their own data. handle checks from most specific to least specific with instanceof, and rethrows anything it does not recognize — the correct pattern for a catch-all that should not silently swallow unrelated bugs. this.constructor.name automatically picks up the subclass’s actual name without repeating a string in every class.

Under the Hood: The Prototype Chain

When JavaScript executes new DatabaseError('Connection refused'), four things happen in order:

  1. The engine allocates a new object and privately links its prototype to DatabaseError.prototype — not yet usable as this inside the constructor.
  2. super(message) runs the Error constructor with that object as this: it sets the message property and prepares a stack string describing the current call site.
  3. Control returns to DatabaseError‘s constructor, where this is now usable; this.name = 'DatabaseError' runs as an ordinary own-property write.
  4. The finished object is returned. Its prototype chain is DatabaseError.prototype → Error.prototype → Object.prototype, exactly what instanceof walks when checking err instanceof Error.
class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = 'DatabaseError';
  }
}

const err = new DatabaseError('Connection refused');

console.log(Object.getPrototypeOf(err) === DatabaseError.prototype);
console.log(Object.getPrototypeOf(DatabaseError.prototype) === Error.prototype);
console.log(typeof err.stack);
console.log(err.stack.split('\n')[0]);

Output:

true
true
string
DatabaseError: Connection refused

err.stack is formatted lazily by the engine the first time it is read, using whatever this.name and this.message are at that moment — which is why setting this.name inside the constructor, before the object is ever inspected, reliably shows up as the first line of the trace even though super() technically ran before that assignment.

Common Mistakes

Mistake 1: Using this before calling super()

In a derived class, this does not exist until the parent constructor runs. Code like class BadError extends Error { constructor(message) { this.message = message; } } throws a ReferenceError: Must call super constructor before accessing 'this' the instant you write new BadError('x') — it never even gets to create the error you meant to throw. Always call super(...) as the very first line of the constructor:

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

try {
  throw new ParseError('Unexpected token', 12);
} catch (error) {
  console.log(`${error.name} on line ${error.line}: ${error.message}`);
}

Output:

ParseError on line 12: Unexpected token

With super(message) called first, this is fully initialized before the constructor adds name and line, so the object behaves like a normal error plus the extra data.

Mistake 2: Forgetting to set this.name

Without it, every custom error still identifies itself as plain 'Error' in logs, stack traces, and toString(), which defeats the purpose of giving it a distinct type:

class TimeoutError extends Error {}

const withoutName = new TimeoutError('Request timed out');
console.log(withoutName.name);
console.log(withoutName.toString());

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

const withName = new FixedTimeoutError('Request timed out');
console.log(withName.name);
console.log(withName.toString());

Output:

Error
Error: Request timed out
FixedTimeoutError
FixedTimeoutError: Request timed out

TimeoutError has no constructor of its own, so it uses the default derived constructor, which forwards arguments to super(...args) but never touches name — leaving it inherited from Error.prototype. toString() builds its string as ${this.name}: ${this.message}, so an unset name silently produces misleading output in every log line.

Mistake 3: Branching on error.message text

Message strings are meant for humans and change wording, punctuation, or language over time — matching them with something like error.message.includes('not found') is brittle and breaks silently the moment someone rewords the message. Give every error type its own class (or a stable, machine-readable code property) and branch with instanceof instead, as shown in the AppError hierarchy above.

Best Practices

  • Always call super(message) as the first statement in the constructor, before touching this.
  • Set this.name to the class name (or this.constructor.name in a shared base class) so logs and toString() show the real error type.
  • Build a small hierarchy — one base class like AppError, with specific subclasses per failure mode — instead of one giant error class with a type string field.
  • Attach structured data (statusCode, field, resource) as real properties, not text baked into the message.
  • When wrapping a lower-level error, use the standard cause option — super(message, { cause: originalError }) — so the original stack is not lost.
  • Prefer instanceof or a dedicated code property over parsing error.message strings.
  • Only catch errors you can meaningfully handle or translate; rethrow anything else so bugs are not silently swallowed.
  • Remember that message and stack are non-enumerable — if you serialize errors for logging, add a toJSON() method or spread the fields explicitly.

Practice Exercises

  1. Create an InsufficientFundsError that extends Error and stores a shortfall property. Write a withdraw(balance, amount) function that throws it when amount exceeds balance, then catch it and print a message such as ‘Cannot withdraw: short by $50’.
  2. Build a two-level hierarchy: a base ApiError class and two subclasses, BadRequestError (400) and UnauthorizedError (401), each setting a numeric statusCode. Write a function that receives an error and logs a different message per subclass using instanceof, with a generic fallback for anything else.
  3. Write a function loadConfig() that throws a plain Error('file not found'). Call it inside try/catch, and in the catch block throw a new ConfigError that preserves the original error using the cause option. Log both error.message and error.cause.message.

Summary

  • A custom error is a class that extends Error, gaining message, stack, and full compatibility with instanceof and try/catch.
  • super(message) must run before this is used, because the parent constructor initializes the instance.
  • Set this.name explicitly — otherwise stack traces and toString() still report plain 'Error'.
  • Extra properties on a custom error turn unstructured text into structured, programmatically usable data.
  • Prototype-chain subclassing is why instanceof Error keeps working for every custom error type.
  • Build hierarchies — a shared base class plus specific subclasses — for layered, precise error handling.
  • Use the cause option to chain errors without discarding the original failure’s context.