Skip to content
Node.js nd error-handling 4 min read

Creating Custom Error Classes

The built-in Error carries a message and a stack trace, but in a real application that is rarely enough. You want to know what kind of failure occurred — a validation problem, a missing record, an upstream timeout — and branch on it without parsing error strings. Custom error classes give each failure a stable type and a machine-readable code, so callers can handle them precisely with instanceof and centralized handlers can map them to HTTP status codes or retry logic.

Extending the Error class

A custom error is just a class that extends Error. The critical detail is calling super(message) so the base constructor records the message and initializes the stack, then setting this.name to the class name so it appears correctly in logs and stringified output. Always assign a stable, machine-readable code too — code is what your handling logic should switch on, never the human-facing message.

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

const err = new AppError('User not found', 'USER_NOT_FOUND');

console.log(err.name);            // class name
console.log(err.code);            // machine-readable code
console.log(err.message);         // human message
console.log(err instanceof Error); // still a real Error
console.log(`${err}`);            // default Error toString

Output:

AppError
USER_NOT_FOUND
User not found
true
AppError: User not found

Setting this.name from this.constructor.name means every subclass automatically reports its own name without repeating the string in each constructor.

A common mistake is forgetting super(message). Without it the message and stack are never set, so your error logs an empty message and a useless trace. Always call super first.

Capturing a clean stack trace

The stack property is set by the V8 engine when the error is constructed. By default the trace includes the custom constructor frame itself, which is noise — you want the trace to start at the line that actually threw. Error.captureStackTrace(this, this.constructor) rewrites the stack to begin at the caller, omitting the constructor. It is a V8-specific API (available in Node.js) and is best wrapped in a guard for portability.

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.code = 'VALIDATION_FAILED';
    this.field = field;

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, ValidationError);
    }
  }
}

function createUser(name) {
  if (!name) throw new ValidationError('name is required', 'name');
  return { name };
}

createUser('');

Output:

ValidationError: name is required
    at createUser (file:///app/index.js:16:15)
    at file:///app/index.js:19:1

The trace now points straight at createUser, with no ValidationError constructor frame in the way.

Typed handling with instanceof

Because custom errors keep their prototype chain, instanceof works at every level — an instance is both a ValidationError and an Error. This lets a single catch block distinguish failure types and respond differently, while still falling through to a generic branch for anything unexpected.

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 'NOT_FOUND');
    this.status = 404;
  }
}

class TimeoutError extends AppError {
  constructor(ms) {
    super(`Operation timed out after ${ms}ms`, 'TIMEOUT');
    this.status = 504;
  }
}

function handle(err) {
  if (err instanceof NotFoundError) return `404: ${err.message}`;
  if (err instanceof TimeoutError) return `504: ${err.message}`;
  if (err instanceof AppError) return `App error (${err.code})`;
  return `Unknown: ${err.message}`;
}

console.log(handle(new NotFoundError('User')));
console.log(handle(new TimeoutError(5000)));
console.log(handle(new Error('boom')));

Output:

404: User not found
504: Operation timed out after 5000ms
Unknown: boom

Branching on code is equally valid and survives bundling or serialization better than instanceof across module boundaries — both are good, code is the more robust default.

Preserving the original cause

When you catch a low-level error and rethrow a domain-specific one, attach the original via the standard cause option (Node.js 16.9+). This keeps the root failure for debugging while presenting a clean type to callers.

class DatabaseError extends AppError {
  constructor(message, cause) {
    super(message, 'DB_ERROR', { cause });
  }
}

// AppError must forward options to super for `cause` to be set:
class AppError extends Error {
  constructor(message, code, options) {
    super(message, options);
    this.name = this.constructor.name;
    this.code = code;
  }
}

try {
  JSON.parse('{ invalid');
} catch (original) {
  const err = new DatabaseError('Failed to load config', original);
  console.log(err.message);
  console.log(err.cause.constructor.name);
}

Output:

Failed to load config
SyntaxError

Custom errors and CommonJS

The pattern is identical under CommonJS — only the export syntax changes. Define the class normally and attach it to module.exports.

// errors.cjs
class AppError extends Error {
  constructor(message, code) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
  }
}

module.exports = { AppError };

Custom error properties reference

PropertySourcePurpose
messagesuper(message)Human-readable description
nameset in constructorClass label shown in logs / toString
codeset in constructorStable, machine-readable identifier
stackV8 / captureStackTraceCall site for debugging
causesuper(msg, { cause })Underlying error that triggered this one
custom (field, status)set in constructorDomain-specific context

Best Practices

  • Always call super(message) first so the base Error sets message and stack correctly.
  • Set this.name to the class name (use this.constructor.name) so logs and stringified errors identify the type.
  • Give every error a stable code and prefer branching on it over fragile message-string matching.
  • Call Error.captureStackTrace(this, this.constructor) to keep the constructor frame out of the trace.
  • Build a small hierarchy from a shared base class so one instanceof AppError check catches all your application errors.
  • Forward the { cause } option to super and use it when rethrowing, so the original failure is never lost.
  • Avoid putting heavy logic in error constructors — they run on the failure path and should stay cheap and side-effect free.
Last updated June 14, 2026
Was this helpful?