Building Custom Error Class Hierarchies in TypeScript
AI generated
<T>
type
TypeScript · Error Handling · Design Patterns
Custom Error Class Hierarchies
structuring domain errors instead of new Error("...") everywhere

The built in Error class from JavaScript is rarely enough for a growing codebase: it carries no error codes, no structured metadata, and no clean serialization. A well thought out error class hierarchy solves these problems with a shared base class, consistent instanceof checks, and cause chaining for connected failure causes.

18 min read Base Class · Error Codes · instanceof · cause TypeScript 5.x · Node.js

1. Why the built in Error class reaches its limits

The native Error class in JavaScript is deliberately minimal: it carries a message, a name, and, depending on the environment, a stack property. That is enough for simple scripts, but once an application grows, exactly the information a structured error handling approach needs starts to be missing: a machine readable error code, additional context data such as an affected entity id, and a clear indication of which layer of the application the error came from. A custom error class hierarchy closes exactly that gap.

Without such an error class hierarchy, many teams resort to string comparisons like error.message.includes("not found") to distinguish error kinds. This approach is fragile, because message texts can silently change during a translation or a minor wording adjustment, breaking the comparison unnoticed. A dedicated class per error kind, recognizable via instanceof, is robust against such text changes, because identity runs through the type rather than the message text.

The third reason for a custom error class hierarchy is consistency across an entire team. Without a shared base class, every developer builds their own ad hoc conventions for error objects, leading to a wildly growing variety of shapes that can neither be logged uniformly nor returned to the client uniformly. A shared base class with clear conventions for error codes, metadata, and serialization brings order here.

2. Designing a base class for domain errors

The first building block of every error class hierarchy is a shared base class, often called DomainError or AppError, from which all more specific error classes inherit. This base class extends the native Error class and adds fields that should be consistently available across the entire application: a code for machine readable identification, an isOperational flag distinguishing expected errors from genuine programming bugs, and optional structured metadata.

An important design decision is whether DomainError should be abstract. In TypeScript, an abstract class prevents developers from accidentally instantiating new DomainError(...) directly instead of using one of the more specific subclasses. This restriction forces every error that is actually thrown to belong to a concrete, named category, which pays off later both for logging and for error handling at the call site.

In the base class constructor, one detail matters especially: Object.setPrototypeOf(this, new.target.prototype) has to be set after the super() call whenever the class inherits from Error across several levels and runs in an environment that does not fully transpile Error to ES6 semantics internally, such as older TypeScript target settings like ES5. Without this fix, instanceof checks on derived classes do not work reliably in such environments.


// Base class for the entire domain error hierarchy
abstract class DomainError extends Error {
  abstract readonly code: string;
  readonly isOperational: boolean;
  readonly timestamp: Date;

  constructor(message: string, isOperational = true) {
    super(message);
    this.name = new.target.name;
    this.isOperational = isOperational;
    this.timestamp = new Date();

    // Required for correct instanceof checks when targeting ES5
    Object.setPrototypeOf(this, new.target.prototype);

    // Node.js: excludes the constructor call itself from the stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, new.target);
    }
  }
}

class NotFoundError extends DomainError {
  readonly code = "NOT_FOUND";

  constructor(readonly resourceType: string, readonly resourceId: string) {
    super(`${resourceType} with id ${resourceId} was not found`);
  }
}

const error = new NotFoundError("Product", "sku-42");
console.log(error instanceof DomainError); // true
console.log(error instanceof Error); // true
console.log(error.code); // "NOT_FOUND"

3. Attaching error codes and metadata type safely

A plain code: string is enough for many cases, but type safety improves noticeably once code gets declared as a literal type instead of a generic string. If every concrete error class in the error class hierarchy carries a fixed, literally typed code, a union of every possible code can be derived automatically, enabling autocomplete and exhaustiveness checking during error handling.

For additional metadata, such as which fields are affected by a validation error, a generic approach in the base class pays off: an optional details field whose concrete type is determined by the respective subclass. That way, ValidationError can carry details: { field: string; constraint: string }[], while RateLimitError instead carries details: { retryAfterSeconds: number }, each matching its concrete error kind.

A commonly used pattern in production ready codebases is to additionally give every error class a static httpStatus property, determining which HTTP status code should be returned when the error gets translated into a response at the API boundary. Coupling this at the source, rather than in a separate mapping table somewhere in the code, reduces the chance that a new error class gets forgotten when the mapping needs to be maintained.


// Error codes as literal types, plus structured metadata per subclass
abstract class DomainError extends Error {
  abstract readonly code: string;
  abstract readonly httpStatus: number;
}

class ValidationError extends DomainError {
  readonly code = "VALIDATION_ERROR" as const;
  readonly httpStatus = 422;

  constructor(readonly details: Array<{ field: string; constraint: string }>) {
    super(`Validation failed for ${details.length} field(s)`);
  }
}

class RateLimitError extends DomainError {
  readonly code = "RATE_LIMIT_EXCEEDED" as const;
  readonly httpStatus = 429;

  constructor(readonly retryAfterSeconds: number) {
    super(`Rate limit exceeded, retry after ${retryAfterSeconds}s`);
  }
}

// Union of all known error codes, derived from the concrete classes
type KnownErrorCode = ValidationError["code"] | RateLimitError["code"];

function logErrorCode(code: KnownErrorCode): void {
  console.log(`Handling known error code: ${code}`);
}

4. Handling instanceof checks and the prototype chain correctly

instanceof is the central mechanism for distinguishing an error class hierarchy at runtime, but it has several pitfalls, especially in mixed build environments. The most common pitfall is a TypeScript target below ES2015, where the generated JavaScript code does not correctly build the prototype chain for classes that inherit from Error. The Object.setPrototypeOf pattern shown in the previous section fixes exactly this problem.

A second pitfall involves duplicate modules: if an application accidentally loads two separate copies of the same error class through a bundler or a monorepo setup, for example because two different versions of a library exist simultaneously in the node_modules tree, instanceof fails even though the error is content wise exactly the same class. This problem is not TypeScript specific, but a general JavaScript module quirk that shows up especially often in TypeScript projects with complex monorepo structures.

As a more robust alternative to plain instanceof, some teams additionally rely on a combination of instanceof and a check of the code field: error instanceof DomainError && error.code === "NOT_FOUND". This double check remains correct even if instanceof fails due to a duplicate module problem, as long as code stays identical across the module boundary as a plain data value.


// Robust checking: instanceof combined with the discriminating code field
function isKnownDomainError(caught: unknown): caught is DomainError {
  return (
    caught instanceof DomainError ||
    // Fallback for duplicate module instances: check shape instead of identity
    (typeof caught === "object" &&
      caught !== null &&
      "code" in caught &&
      "isOperational" in caught)
  );
}

function handleCaughtError(caught: unknown): void {
  if (caught instanceof NotFoundError) {
    console.log(`Not found: ${caught.resourceType}/${caught.resourceId}`);
    return;
  }
  if (isKnownDomainError(caught)) {
    console.log(`Known domain error: ${caught.code}`);
    return;
  }
  console.error("Unexpected error", caught);
  throw caught; // re-throw truly unknown errors, do not swallow them
}

5. Error hierarchies for different error categories

A flat list of twenty classes inheriting directly from DomainError quickly becomes unwieldy. A multi level error class hierarchy works better, where a middle layer introduces broad categories such as a ValidationError base, a NotFoundError base, an AuthorizationError base, and an ExternalServiceError base, from which concrete, specific error classes then inherit. This intermediate layer lets code react to an entire category with a single instanceof check, without enumerating every concrete subclass individually.

A practical example: a global Express error handler can react uniformly to HTTP 401 or 403 with if (error instanceof AuthorizationError), regardless of whether it is a MissingTokenError, an ExpiredTokenError, or an InsufficientRoleError. Each of these concrete classes can still carry its own specific metadata, used in a more detailed log entry or a more specific client error message, without breaking the broad categorization inside the handler.

The depth of the hierarchy should follow actual behavior, not purely conceptual similarity. Two error classes should only share a common intermediate class when code genuinely exists that wants to treat both error kinds identically. An overly deep, purely taxonomic hierarchy without real behavioral grounding tends to hurt maintainability rather than improve it.


// Intermediate category classes group related, specific error classes
abstract class AuthorizationError extends DomainError {
  readonly httpStatus = 403;
}

class MissingTokenError extends AuthorizationError {
  readonly code = "MISSING_TOKEN" as const;
  constructor() {
    super("No authentication token was provided");
  }
}

class InsufficientRoleError extends AuthorizationError {
  readonly code = "INSUFFICIENT_ROLE" as const;
  constructor(readonly requiredRole: string) {
    super(`Role "${requiredRole}" is required for this operation`);
  }
}

// A single check handles the whole category, regardless of the concrete class
function expressErrorHandler(error: unknown): { status: number; body: object } {
  if (error instanceof AuthorizationError) {
    return { status: error.httpStatus, body: { code: error.code, message: error.message } };
  }
  if (error instanceof DomainError) {
    return { status: error.httpStatus, body: { code: error.code, message: error.message } };
  }
  return { status: 500, body: { code: "INTERNAL_ERROR", message: "Unexpected error" } };
}

6. Error serialization for logging and API responses

An error class hierarchy that only lives internally within one process is only half the solution. As soon as an error needs to be logged, sent to a log aggregator such as Elasticsearch, or returned to a client as JSON, it becomes clear that JSON.stringify(error) on a native Error instance produces an empty object {} by default, because message and stack are defined as non enumerable properties. A well thought out error class hierarchy has to solve this serialization explicitly.

The usual solution is a toJSON() method on the base class that explicitly copies all relevant fields into an enumerated object: code, message, timestamp, and optionally details. The stack trace should deliberately not end up in client responses by default in production environments, since it exposes internal file paths and code structure, but it absolutely should end up in internal log entries visible only to the own team.

For structured logging frameworks such as Pino or Winston, it pays off to additionally define a dedicated toLogObject() method that carries more detail than toJSON(), such as the full stack trace and internal debug information that should never reach the client. This separation between client safe and internally complete serialization is a central building block of every production ready error class hierarchy.


// Explicit serialization: safe for clients, richer for internal logs
abstract class DomainError extends Error {
  abstract readonly code: string;
  abstract readonly httpStatus: number;
  readonly timestamp = new Date();

  toJSON(): Record<string, unknown> {
    return {
      code: this.code,
      message: this.message,
      timestamp: this.timestamp.toISOString(),
    };
  }

  toLogObject(): Record<string, unknown> {
    return {
      ...this.toJSON(),
      name: this.name,
      stack: this.stack,
    };
  }
}

const error = new NotFoundError("Product", "sku-42");
console.log(JSON.stringify(error)); // uses toJSON automatically
logger.error(error.toLogObject());

const logger = { error: (_payload: Record<string, unknown>): void => {} };

7. Chaining error classes with cause chaining

In multi layer applications, an error often originates in a deep layer, such as a database driver, but gets translated into a domain specific error class in a higher layer. Without cause chaining, the original, technical failure cause gets lost in that process, making debugging considerably harder. Since ES2022, the native Error class supports a cause field in the constructor options object, which TypeScript fully types starting from version 4.6.

The cause field allows throwing a domain specific error class that references the original, technical error as its cause, without losing the failure information from the lower layer. When logging, the entire chain from error through error.cause down to the actual root cause can then be printed recursively, which is especially valuable for database errors, network errors, or errors inside third party libraries.

An important design aspect: the error class hierarchy should consistently support cause in the base class, not just in individual subclasses, so every derived error class automatically benefits from this mechanism. A small utility function that recursively translates the entire cause chain into an array of log objects makes this information directly usable for structured logging.


// Cause chaining preserves the original low-level error
class DatabaseConnectionError extends DomainError {
  readonly code = "DB_CONNECTION_ERROR" as const;
  readonly httpStatus = 503;
}

async function loadUserProfile(userId: string): Promise<unknown> {
  try {
    return await queryDatabase(userId);
  } catch (rawError) {
    // The original driver error is preserved via the cause option
    throw new DatabaseConnectionError("Failed to load user profile", { cause: rawError });
  }
}

function collectCauseChain(error: unknown): string[] {
  const messages: string[] = [];
  let current: unknown = error;
  while (current instanceof Error) {
    messages.push(current.message);
    current = current.cause;
  }
  return messages;
}

async function queryDatabase(_userId: string): Promise<unknown> {
  return {};
}

8. Common mistakes when building error hierarchies

The most common mistake is forgetting Object.setPrototypeOf, or a modern TypeScript target, causing instanceof checks on derived classes to silently fail. This bug often shows up late, because the error class works correctly in simple tests but suddenly stops being recognized in a production build pipeline with an older compile target.


// WRONG: missing prototype fix breaks instanceof on older compile targets
class BrokenError extends Error {
  constructor(message: string) {
    super(message);
    // Missing: Object.setPrototypeOf(this, BrokenError.prototype);
  }
}

// WRONG: swallowing the cause instead of chaining it
async function badFetch(): Promise<void> {
  try {
    await externalCall();
  } catch {
    throw new Error("External call failed"); // original cause is lost
  }
}

// RIGHT: always fix the prototype chain and preserve the cause
class FixedError extends Error {
  constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, FixedError.prototype);
  }
}

async function goodFetch(): Promise<void> {
  try {
    await externalCall();
  } catch (rawError) {
    throw new Error("External call failed", { cause: rawError });
  }
}

async function externalCall(): Promise<void> {}

A second widespread mistake is a hierarchy that stays too flat without categorization, where every error class inherits directly from Error without a shared base class. That forces every consuming site in the code to import and check every concrete error class individually, instead of catching an entire category with a single instanceof DomainError check. A third mistake is leaving out isOperational or a similar flag entirely, which makes it impossible to distinguish genuine programming bugs from expected business logic errors, something that matters especially for crash reporting and process monitoring.

9. Approaches in comparison

The table below compares different approaches to modeling error classes.

Criterion new Error(message) Flat Custom Classes Error Class Hierarchy
Machine readable codes No Yes, per class Yes, consistent via base class
Categorized instanceof checks No No, each class separately Yes, via intermediate classes
Serialization for logs/API Manual work needed Inconsistent per class Consistent via toJSON
Cause chaining Possible manually Possible manually Standardized in base class
Maintainability in a team Low Moderate High

For small scripts, new Error(message) is enough. Once several developers work on different error kinds and these need to be logged, categorized, and returned to clients consistently, the upfront effort of a full error class hierarchy pays off noticeably.

Mironsoft

Backend architecture, logging, and error handling for Node.js

An error hierarchy your entire team actually understands?

We design a complete error class hierarchy with error codes, serialization, and cause chaining for your Node.js and TypeScript backends.

Hierarchy Design

Designing a base class, categories, and error codes for your backend

Logging Integration

Structured serialization for Pino, Winston, and Elasticsearch

Migration

Gradually migrating existing new Error calls onto the hierarchy

10. Summary

A well structured error class hierarchy replaces scattered new Error("...") calls with a shared base class carrying error codes, structured metadata, and consistent serialization. An abstract base class prevents accidental direct instantiation, while Object.setPrototypeOf and modern compile targets ensure instanceof checks on derived classes work reliably.

Intermediate layers for categories such as AuthorizationError allow broad error handling without enumerating every concrete subclass individually. toJSON() and toLogObject() separate client safe from internally complete serialization, and cause chaining preserves the original technical failure cause across several layers. Together this produces an error class hierarchy that noticeably eases both debugging and error handling across the entire team.

Error Class Hierarchies in TypeScript, the essentials at a glance

Base Class

An abstract DomainError class with error code, isOperational flag, and timestamp for every error kind.

instanceof Safety

Object.setPrototypeOf after super() guarantees correct instanceof checks even on older compile targets.

Serialization

toJSON for client responses, toLogObject with the full stack trace for internal logs.

Cause Chaining

The native cause field preserves the original technical failure cause across several layers.

11. FAQ: Error Class Hierarchies in TypeScript

1Why is the native Error class not enough?
It only carries message, name, and stack, no error codes or structured metadata.
2Should the base class be abstract?
Yes, that prevents accidental direct instantiation of the base class.
3Why Object.setPrototypeOf?
Fixes broken instanceof checks for TypeScript compile targets below ES2015.
4How to model error codes type safely?
As a literally typed readonly field per class, not as a generic string.
5What is cause chaining?
The native cause field links a domain error to its original technical cause.
6How do I serialize errors for the API?
Via a toJSON method with only client safe fields, never the full stack trace.
7What does isOperational mean?
Distinguishes expected errors from genuine programming bugs, useful for crash reporting.
8How deep should the hierarchy be?
Only introduce intermediate classes when code genuinely wants to treat several error kinds identically.
9What if instanceof still fails?
Usually duplicate modules in monorepos, a fallback check on the code field helps.
10Does it pay off for small projects?
For very small scripts new Error is usually enough, the structure pays off with more developers.