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.
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.
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.
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.
ValidationError
Email is requiredThe 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
Frequently Asked Questions
Do I need to set the prototype manually when extending Error?
How do I add custom properties to my error class?
Can I use custom error classes with async/await?
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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.