Custom Error Classes in TypeScript

Learn how to create custom error classes that extend the built-in Error, add meaningful properties like status codes and field names, and use them with type-safe catch blocks.

6 min read

A custom error class in TypeScript extends the built-in Error class and adds properties specific to your application's needs. Instead of throwing generic errors with string messages, you throw typed objects that carry structured information like HTTP status codes, validation field names, or error codes.

This makes error handling more precise. A catch block can use instanceof to identify exactly what kind of error occurred and react accordingly, with full TypeScript type narrowing.

Creating Your First Custom Error

Start by extending Error and setting the name property in the constructor. The name is what appears in stack traces and error reports, so set it to the class name.

typescripttypescript
class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

You now have a typed error class. Throw it the same way you would throw any built-in error, then narrow it in the catch block before reading its properties.

typescripttypescript
try {
  throw new ValidationError("Email is required");
} catch (err) {
  if (err instanceof ValidationError) {
    console.error(err.name);
    console.error(err.message);
  }
}

The output shows the custom name alongside your message, confirming that the thrown object kept its identity all the way to the catch block.

texttext
ValidationError
Email is required

The instanceof check narrows the caught value from unknown to ValidationError inside the if block, so its name and message properties are both safe to read.

Adding Custom Properties

Real applications need more than a message. Use the public modifier on constructor parameters to create class properties with zero extra boilerplate.

typescripttypescript
class ApiError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public endpoint: string
  ) {
    super(message);
    this.name = "ApiError";
  }
}

The public keyword before each constructor parameter declares it as a class property and assigns the argument automatically, so the status code and endpoint need no separate declaration line.

typescripttypescript
async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
 
  if (!response.ok) {
    throw new ApiError(
      "Failed to fetch user",
      response.status,
      `/api/users/${id}`
    );
  }
 
  return response.json();
}

Now the catch block has access to the status code and endpoint, which is exactly the extra context a generic Error object could not carry.

typescripttypescript
try {
  await fetchUser("42");
} catch (err) {
  if (err instanceof ApiError) {
    console.error(`${err.statusCode} on ${err.endpoint}: ${err.message}`);
  }
}

Each property is fully typed and accessible after the instanceof guard, so the status code reads as a number and the endpoint reads as a string with no extra casting.

Choosing Between readonly and Mutable Properties

Most error properties should be readonly. An error represents something that already happened, and its details should not change after construction.

typescripttypescript
class DatabaseError extends Error {
  constructor(
    message: string,
    public readonly query: string,
    public readonly code: string
  ) {
    super(message);
    this.name = "DatabaseError";
  }
}

Marking properties as readonly prevents accidental reassignment and communicates intent clearly. The compiler will catch any attempt to modify these values after the error is created.

Organising a Hierarchy of Errors

When your application handles different categories of errors, create a base class that shared subclasses can extend. Every specific error type still identifies itself through its own name.

typescripttypescript
class AppError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "AppError";
  }
}
 
class NetworkError extends AppError {
  constructor(message: string, public statusCode: number) {
    super(message);
    this.name = "NetworkError";
  }
}

A second subclass follows the same shape as NetworkError, extending the same base but tagging itself with a different name.

typescripttypescript
class AuthError extends AppError {
  constructor(message: string) {
    super(message);
    this.name = "AuthError";
  }
}

Catch blocks can now handle errors at different levels of specificity. Check for the most specific type first, then fall back to broader types, ending with a generic message for anything unexpected.

typescripttypescript
try {
  await authenticateUser(credentials);
} catch (err) {
  if (err instanceof AuthError) {
    redirectToLogin();
  } else if (err instanceof AppError) {
    showErrorBanner(err.message);
  } else {
    showErrorBanner("An unexpected error occurred");
  }
}

TypeScript narrows correctly at each level because AuthError extends AppError. An AuthError instance passes both checks, so the more specific branch has to come first or it would never run.

Custom error class hierarchy

The diagram shows how custom errors extend from a common base. Catch blocks targeting AppError will handle all application errors, while more specific checks target exactly one subtype.

The Prototype Chain Issue

When targeting ES5 or earlier, extending built-in classes like Error can break the prototype chain. This means instanceof checks might fail at runtime even though the TypeScript compiles correctly.

typescripttypescript
class LegacyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "LegacyError";
    Object.setPrototypeOf(this, LegacyError.prototype);
  }
}

The Object.setPrototypeOf call restores the correct prototype chain. For ES2015 and above targets, this line is unnecessary because the class syntax already sets up inheritance correctly, and most modern TypeScript projects target ES2020 or later.

Using Custom Errors with Async Code

Custom error classes work identically in async functions. The error thrown inside an async function is the same object caught by the caller.

typescripttypescript
async function loadConfig(path: string) {
  const response = await fetch(path);
 
  if (response.status === 404) {
    throw new NetworkError("Config file not found", 404);
  }
 
  if (!response.ok) {
    throw new NetworkError("Failed to load config", response.status);
  }
 
  return response.json();
}

The caller narrows with instanceof just like in synchronous code, since awaiting a rejected promise simply re-throws the original error into the surrounding catch block.

typescripttypescript
try {
  const config = await loadConfig("/config.json");
} catch (err) {
  if (err instanceof NetworkError) {
    console.error(`Network error ${err.statusCode}: ${err.message}`);
  }
}

The async boundary does not change anything about how the error type is preserved and narrowed. The thrown object is the same on both sides of the await.

For more on handling errors before they propagate, see unknown errors in TypeScript. For a different approach that avoids throwing entirely, see Result pattern in TypeScript.

Rune AI

Rune AI

Key Insights

  • Extend the built-in Error class to create typed custom errors.
  • Always set this.name in the constructor so error identification works correctly.
  • Use readonly properties to attach domain data like status codes or field names.
  • Narrow caught errors with instanceof to handle specific error types safely.
  • For ES2015+ targets, the prototype chain is correct without manual fixes.
RunePowered by Rune AI

Frequently Asked Questions

Do I need to set the prototype manually when extending Error?

Only when targeting ES5 or earlier. For ES2015 and above targets, the prototype chain is set correctly by the class syntax. Most modern TypeScript projects target ES2020 or later and do not need manual prototype fixes.

How do I add custom properties to my error class?

Add them as constructor parameters with TypeScript's parameter property shorthand (public or readonly). Pass them after the message argument, call super(message) first, then set this.name to the class name for accurate error identification.

Can I use custom error classes with async/await?

Yes. Custom errors thrown inside async functions are caught normally. Use instanceof in your catch block to distinguish between different error types and handle each case appropriately.

Conclusion

Custom error classes let you attach domain-specific information to thrown errors while keeping full type safety. Extend the built-in Error, set this.name in the constructor, and use instanceof in catch blocks to handle each error type precisely. The pattern scales from simple validation errors to structured API error responses.