Skip to main content

How to Create Custom Errors and Extend the Error Class in JavaScript

The built-in error types like TypeError and RangeError cover generic programming mistakes, but real applications need errors that describe domain-specific problems. When your authentication system rejects invalid credentials, when a database query times out, or when a payment processor declines a card, generic errors do not communicate enough information. You need errors that carry meaningful names, specific error codes, and relevant context.

JavaScript lets you create custom error classes by extending the built-in Error constructor. These custom errors integrate seamlessly with try...catch, instanceof checks, and the prototype chain. This guide covers how to build them properly, how to use the wrapper pattern for layered error handling, and how to aggregate multiple errors into a single response.

Creating Custom Error Classes

The simplest way to create a custom error is to extend the Error class using the class syntax. Your custom error automatically inherits name, message, and stack properties.

Basic Custom Error

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

try {
throw new ValidationError("Email format is invalid");
} catch (e) {
console.log(e.name); // "ValidationError"
console.log(e.message); // "Email format is invalid"
console.log(e.stack); // Full stack trace
console.log(e instanceof ValidationError); // true
console.log(e instanceof Error); // true
}

The super(message) call is essential. It invokes the parent Error constructor, which sets up the message property and generates the stack trace. Without it, your error object would be incomplete.

Adding Custom Properties

Custom errors become truly useful when they carry additional context beyond a message string:

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

class HttpError extends Error {
constructor(statusCode, message, response = null) {
super(message);
this.name = "HttpError";
this.statusCode = statusCode;
this.response = response;
}
}

class DatabaseError extends Error {
constructor(message, query = null, code = null) {
super(message);
this.name = "DatabaseError";
this.query = query;
this.code = code;
}
}

// Usage
try {
throw new HttpError(404, "User not found", { endpoint: "/api/users/99" });
} catch (e) {
console.log(e.name); // "HttpError"
console.log(e.message); // "User not found"
console.log(e.statusCode); // 404
console.log(e.response); // { endpoint: "/api/users/99" }
console.log(e.stack); // Full stack trace
}

The extra properties let you write precise error handling logic:

try {
throw new HttpError(503, "Service unavailable");
} catch (e) {
if (e instanceof HttpError) {
if (e.statusCode >= 500) {
console.log("Server error - retry later");
} else if (e.statusCode === 404) {
console.log("Resource not found");
} else if (e.statusCode === 401) {
console.log("Authentication required - redirect to login");
}
}
}

Multiple Levels of Custom Errors

You can build an error hierarchy where specialized errors extend more general custom errors:

class AppError extends Error {
constructor(message, code) {
super(message);
this.name = "AppError";
this.code = code;
this.timestamp = new Date().toISOString();
}
}

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

class TokenExpiredError extends AuthError {
constructor(expiredAt) {
super("Authentication token has expired", "TOKEN_EXPIRED");
this.name = "TokenExpiredError";
this.expiredAt = expiredAt;
}
}

class InvalidCredentialsError extends AuthError {
constructor() {
super("Invalid username or password", "INVALID_CREDENTIALS");
this.name = "InvalidCredentialsError";
}
}

// All levels of instanceof work
const err = new TokenExpiredError(new Date("2024-01-01"));

console.log(err instanceof TokenExpiredError); // true
console.log(err instanceof AuthError); // true
console.log(err instanceof AppError); // true
console.log(err instanceof Error); // true

console.log(err.name); // "TokenExpiredError"
console.log(err.code); // "TOKEN_EXPIRED"
console.log(err.timestamp); // "2024-..." (inherited from AppError)
console.log(err.expiredAt); // Date object

This hierarchy lets you catch errors at whatever granularity you need:

try {
authenticateUser(token);
} catch (e) {
if (e instanceof TokenExpiredError) {
// Specific: redirect to token refresh
refreshToken();
} else if (e instanceof AuthError) {
// General auth issue: redirect to login
redirectToLogin();
} else if (e instanceof AppError) {
// Known app error: show user-friendly message
showError(e.message);
} else {
// Unknown error: log and show generic message
logToService(e);
showError("An unexpected error occurred");
}
}

Extending Error: Best Practices

Creating custom errors looks simple, but there are several pitfalls that can make your errors behave incorrectly. Following these best practices ensures your errors work properly in all situations.

Always Call super(message) First

The super() call must be the first statement in your constructor. It initializes the error's internal state, including the stack trace capture:

// WRONG: accessing 'this' before super()
class BadError extends Error {
constructor(message) {
this.name = "BadError"; // ReferenceError: Must call super before accessing 'this'
super(message);
}
}

// CORRECT: super() first, then customize
class GoodError extends Error {
constructor(message) {
super(message);
this.name = "GoodError";
}
}

Set this.name to Match the Class Name

By default, all errors inherit name: "Error" from Error.prototype. You should override it to match your class name. This affects how the error appears in stack traces and console.error output:

class PaymentError extends Error {
constructor(message) {
super(message);
this.name = "PaymentError"; // Matches the class name
}
}

const err = new PaymentError("Card declined");
console.log(err.toString()); // "PaymentError: Card declined"
console.log(err.stack);
// PaymentError: Card declined
// at Object.<anonymous> (file.js:10:13)
// ...

Without setting this.name:

class PaymentError extends Error {
constructor(message) {
super(message);
// Forgot to set this.name
}
}

const err = new PaymentError("Card declined");
console.log(err.toString()); // "Error: Card declined" (misleading!)
console.log(err.name); // "Error" (wrong!)

Automatic name from Class Name

To avoid repetition and ensure name always matches the class, you can derive it automatically:

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

class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, "NOT_FOUND");
// this.name is automatically "NotFoundError" via this.constructor.name
}
}

class ConflictError extends AppError {
constructor(message) {
super(message, "CONFLICT");
// this.name is automatically "ConflictError"
}
}

const err1 = new NotFoundError("User");
console.log(err1.name); // "NotFoundError" (automatic!)

const err2 = new ConflictError("Email already exists");
console.log(err2.name); // "ConflictError" (automatic!)
tip

Using this.name = this.constructor.name in your base custom error class means all subclasses automatically get the correct name without having to set it manually. This reduces boilerplate and eliminates a common source of mistakes.

Preserve Stack Trace with Error.captureStackTrace (V8)

In V8 environments (Chrome, Node.js), you can use Error.captureStackTrace to exclude the error constructor itself from the stack trace. This makes the trace point to where the error was thrown, not where the error object was constructed:

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

// V8-specific: cleaner stack traces
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}

function validateAge(age) {
if (age < 0) {
throw new AppError("Age cannot be negative", "INVALID_AGE");
}
}

try {
validateAge(-5);
} catch (e) {
console.log(e.stack);
// Without captureStackTrace:
// AppError: Age cannot be negative
// at new AppError (file.js:4:5) ← internal constructor noise
// at validateAge (file.js:12:11)
// ...

// With captureStackTrace:
// AppError: Age cannot be negative
// at validateAge (file.js:12:11) ← clean, starts at throw site
// ...
}

Make Custom Properties Serializable

When sending errors to logging services or APIs, custom properties need to survive JSON serialization. By default, JSON.stringify on an Error object produces "{}" because message, name, and stack are non-enumerable:

const err = new Error("test");
console.log(JSON.stringify(err)); // "{}" (empty!)

Add a toJSON method to your custom errors:

class AppError extends Error {
constructor(message, code, details = {}) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
this.timestamp = new Date().toISOString();

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

toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
details: this.details,
timestamp: this.timestamp,
stack: this.stack
};
}
}

const err = new AppError("Not found", "NOT_FOUND", { userId: 42 });
console.log(JSON.stringify(err, null, 2));
// {
// "name": "AppError",
// "message": "Not found",
// "code": "NOT_FOUND",
// "details": { "userId": 42 },
// "timestamp": "2024-...",
// "stack": "AppError: Not found\n at ..."
// }

Complete Base Error Template

Here is a production-ready base error class that incorporates all best practices:

class AppError extends Error {
constructor(message, options = {}) {
super(message);

this.name = this.constructor.name;
this.code = options.code || "UNKNOWN_ERROR";
this.statusCode = options.statusCode || 500;
this.details = options.details || {};
this.isOperational = options.isOperational !== undefined
? options.isOperational
: true;
this.timestamp = new Date().toISOString();

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

toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
details: this.details,
timestamp: this.timestamp,
stack: this.stack
};
}
}

// Subclasses become very clean
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`, {
code: "NOT_FOUND",
statusCode: 404,
details: { resource, id }
});
}
}

class UnauthorizedError extends AppError {
constructor(reason = "Authentication required") {
super(reason, {
code: "UNAUTHORIZED",
statusCode: 401
});
}
}

class RateLimitError extends AppError {
constructor(retryAfter) {
super("Too many requests", {
code: "RATE_LIMITED",
statusCode: 429,
details: { retryAfter }
});
}
}

// Usage
const err = new NotFoundError("User", 42);
console.log(err.name); // "NotFoundError"
console.log(err.message); // "User with id 42 not found"
console.log(err.code); // "NOT_FOUND"
console.log(err.statusCode); // 404
console.log(err.details); // { resource: "User", id: 42 }

The isOperational flag distinguishes between expected errors (bad user input, network timeouts) and programming bugs (null reference, type mismatch). This distinction is critical in production error handling.

Wrapping Exceptions: The Wrapper Error Pattern

As applications grow, functions call other functions, which call other functions. An error thrown deep inside this chain might be a low-level technical detail that the caller does not understand or care about. The wrapper pattern (also called error wrapping or error chaining) catches low-level errors and wraps them in higher-level, more meaningful errors while preserving the original error for debugging.

The Problem: Low-Level Errors Leaking Upward

function readUserFromDB(userId) {
// Simulated database error
throw new Error("ECONNREFUSED: Connection to 192.168.1.100:5432 refused");
}

function getUserProfile(userId) {
const user = readUserFromDB(userId); // Throws a raw database error
return { ...user, displayName: `${user.first} ${user.last}` };
}

// The API handler receives a raw database connection error
// It has no idea what level of the system failed or what to tell the user
try {
getUserProfile(42);
} catch (e) {
console.log(e.message);
// "ECONNREFUSED: Connection to 192.168.1.100:5432 refused"
// This is a database implementation detail: the caller shouldn't see it
}

The Solution: Wrap Errors with Context

Catch the low-level error, create a higher-level error that describes the problem from the caller's perspective, and attach the original error as the cause:

class AppError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause });
this.name = this.constructor.name;
this.code = options.code || "UNKNOWN_ERROR";

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

class DatabaseError extends AppError {
constructor(message, cause) {
super(message, { code: "DATABASE_ERROR", cause });
}
}

class UserServiceError extends AppError {
constructor(message, cause) {
super(message, { code: "USER_SERVICE_ERROR", cause });
}
}

function readUserFromDB(userId) {
try {
// Simulated low-level failure
throw new Error("ECONNREFUSED: Connection to 192.168.1.100:5432 refused");
} catch (e) {
// Wrap in a DatabaseError
throw new DatabaseError(`Failed to read user ${userId} from database`, e);
}
}

function getUserProfile(userId) {
try {
const user = readUserFromDB(userId);
return { ...user, displayName: `${user.first} ${user.last}` };
} catch (e) {
// Wrap in a higher-level service error
throw new UserServiceError(`Failed to get profile for user ${userId}`, e);
}
}

The cause Property (ES2022)

ES2022 introduced native support for error chaining via the cause option in the Error constructor. When you pass { cause: originalError } to super(), the original error is attached as error.cause:

try {
getUserProfile(42);
} catch (e) {
console.log(e.name); // "UserServiceError"
console.log(e.message); // "Failed to get profile for user 42"
console.log(e.code); // "USER_SERVICE_ERROR"

// The wrapped error
console.log(e.cause.name); // "DatabaseError"
console.log(e.cause.message); // "Failed to read user 42 from database"

// The original root cause
console.log(e.cause.cause.message);
// "ECONNREFUSED: Connection to 192.168.1.100:5432 refused"
}

Unwinding the Full Error Chain

You can walk the cause chain to get the full picture:

function getErrorChain(error) {
const chain = [];
let current = error;

while (current) {
chain.push({
name: current.name,
message: current.message,
code: current.code || undefined
});
current = current.cause;
}

return chain;
}

try {
getUserProfile(42);
} catch (e) {
const chain = getErrorChain(e);
console.log(JSON.stringify(chain, null, 2));
}

Output:

[
{
"name": "UserServiceError",
"message": "Failed to get profile for user 42",
"code": "USER_SERVICE_ERROR"
},
{
"name": "DatabaseError",
"message": "Failed to read user 42 from database",
"code": "DATABASE_ERROR"
},
{
"name": "Error",
"message": "ECONNREFUSED: Connection to 192.168.1.100:5432 refused"
}
]

A Real-World Wrapper Pattern

class AppError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause });
this.name = this.constructor.name;
this.code = options.code || "UNKNOWN";
this.statusCode = options.statusCode || 500;

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

// API layer errors
class ApiError extends AppError {
constructor(message, options = {}) {
super(message, { ...options, code: options.code || "API_ERROR" });
}
}

// Service layer wrapping
async function fetchUserOrders(userId) {
try {
const response = await fetch(`/api/users/${userId}/orders`);

if (!response.ok) {
throw new ApiError(`HTTP ${response.status}: ${response.statusText}`, {
statusCode: response.status,
code: "HTTP_ERROR"
});
}

try {
return await response.json();
} catch (parseError) {
throw new ApiError("Failed to parse order data", {
cause: parseError,
code: "PARSE_ERROR"
});
}
} catch (e) {
if (e instanceof ApiError) {
throw e; // Already wrapped, pass it through
}

// Network error or other unexpected failure
throw new ApiError("Failed to fetch user orders", {
cause: e,
code: "NETWORK_ERROR"
});
}
}
info

The wrapper pattern follows a simple rule: each layer of your application should catch errors from the layer below and re-throw them as errors meaningful to the current layer. Low-level details (connection strings, SQL queries, file paths) stay encapsulated, while high-level code sees domain-relevant error descriptions.

instanceof for Error Type Checking

The instanceof operator is the standard way to determine what type of error was caught. Because custom errors extend Error through the prototype chain, instanceof checks work at every level of your error hierarchy.

Basic Type Checking

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

class RequiredFieldError extends ValidationError {
constructor(field) {
super(`${field} is required`, field);
}
}

class FormatError extends ValidationError {
constructor(field, expectedFormat) {
super(`${field} has invalid format (expected ${expectedFormat})`, field);
this.expectedFormat = expectedFormat;
}
}

function processInput(data) {
if (!data.email) {
throw new RequiredFieldError("email");
}
if (!data.email.includes("@")) {
throw new FormatError("email", "user@domain.com");
}
if (!data.age) {
throw new RequiredFieldError("age");
}
if (data.age < 0 || data.age > 150) {
throw new RangeError("Age must be between 0 and 150");
}
return data;
}

Ordering instanceof Checks: Most Specific First

Because instanceof returns true for any constructor in the prototype chain, you must check from most specific to most general:

try {
processInput({ email: "bad-email", age: 25 });
} catch (e) {
// CORRECT: most specific first
if (e instanceof RequiredFieldError) {
console.log(`Missing required field: ${e.field}`);
} else if (e instanceof FormatError) {
console.log(`Bad format for ${e.field}: expected ${e.expectedFormat}`);
} else if (e instanceof ValidationError) {
console.log(`Validation failed: ${e.message}`);
} else if (e instanceof RangeError) {
console.log(`Out of range: ${e.message}`);
} else if (e instanceof Error) {
console.log(`Unexpected error: ${e.message}`);
}
}

Putting a more general check first would prevent specific checks from ever being reached:

try {
processInput({ email: "bad-email" });
} catch (e) {
// WRONG: general check catches everything
if (e instanceof ValidationError) {
// RequiredFieldError and FormatError both match here
// because they extend ValidationError
console.log("Some validation error");
} else if (e instanceof RequiredFieldError) {
// This NEVER runs because the check above already matched
console.log("Missing field:", e.field);
}
}

instanceof vs. Error name Property

You might wonder whether to use instanceof or check the name property. Both work, but they have different trade-offs:

try {
throw new ValidationError("invalid input", "email");
} catch (e) {
// Using instanceof: checks the prototype chain
if (e instanceof ValidationError) {
console.log("Caught via instanceof");
}

// Using name: checks a string property
if (e.name === "ValidationError") {
console.log("Caught via name");
}
}
ApproachProsCons
instanceofCatches subclasses too. Type-safe. IDE autocomplete.Fails across different realms (iframes, different modules in some bundlers).
e.name === "..."Works across realms. Simple string comparison.Does not catch subclasses. Relies on the name property being set correctly.
e.code === "..."Decoupled from class hierarchy. Good for APIs.Requires discipline to maintain unique codes.

For most applications, instanceof is the right choice. For errors that cross boundaries (between iframes, Web Workers, or serialized/deserialized errors), use the name or code property.

A Pattern for Exhaustive Error Handling

function handleError(error) {
if (error instanceof RequiredFieldError) {
return {
status: 400,
body: {
error: "MISSING_FIELD",
field: error.field,
message: `Please provide ${error.field}`
}
};
}

if (error instanceof FormatError) {
return {
status: 400,
body: {
error: "INVALID_FORMAT",
field: error.field,
message: error.message,
expectedFormat: error.expectedFormat
}
};
}

if (error instanceof ValidationError) {
return {
status: 400,
body: {
error: "VALIDATION_ERROR",
message: error.message
}
};
}

// Unknown error: log it and return generic response
console.error("Unhandled error type:", error);
return {
status: 500,
body: {
error: "INTERNAL_ERROR",
message: "An unexpected error occurred"
}
};
}

Error Aggregation Pattern

Sometimes a single operation can produce multiple errors. Form validation is the classic example: you want to report all invalid fields at once, not stop at the first one. The error aggregation pattern collects multiple errors and reports them together.

The Problem: One Error at a Time

function validateUser(data) {
if (!data.name) {
throw new ValidationError("Name is required", "name");
}
// If name is missing, we never check email or age
if (!data.email) {
throw new ValidationError("Email is required", "email");
}
if (!data.age) {
throw new ValidationError("Age is required", "age");
}
}

try {
validateUser({}); // Only reports "Name is required"
} catch (e) {
console.log(e.message); // "Name is required" (what about email and age?)
}

The user fixes their name, resubmits, gets another error for email, fixes that, resubmits, gets another for age. Three round trips for three errors that could have been reported at once.

The Solution: Collect and Aggregate

class AggregateValidationError extends Error {
constructor(errors) {
const message = `${errors.length} validation error(s): ${
errors.map(e => e.message).join("; ")
}`;
super(message);
this.name = "AggregateValidationError";
this.errors = errors;
}

toJSON() {
return {
name: this.name,
message: this.message,
errors: this.errors.map(e => ({
field: e.field,
message: e.message,
code: e.code
}))
};
}
}

function validateUser(data) {
const errors = [];

if (!data.name || data.name.trim() === "") {
errors.push(new ValidationError("Name is required", "name"));
} else if (data.name.length < 2) {
errors.push(new ValidationError("Name must be at least 2 characters", "name"));
}

if (!data.email) {
errors.push(new ValidationError("Email is required", "email"));
} else if (!data.email.includes("@")) {
errors.push(new ValidationError("Email format is invalid", "email"));
}

if (data.age === undefined || data.age === null) {
errors.push(new ValidationError("Age is required", "age"));
} else if (typeof data.age !== "number" || data.age < 0 || data.age > 150) {
errors.push(new ValidationError("Age must be a number between 0 and 150", "age"));
}

if (data.password !== undefined) {
if (data.password.length < 8) {
errors.push(new ValidationError("Password must be at least 8 characters", "password"));
}
if (!/[A-Z]/.test(data.password)) {
errors.push(new ValidationError("Password must contain an uppercase letter", "password"));
}
if (!/[0-9]/.test(data.password)) {
errors.push(new ValidationError("Password must contain a digit", "password"));
}
}

if (errors.length > 0) {
throw new AggregateValidationError(errors);
}

return data;
}

Now the caller gets all errors at once:

try {
validateUser({ name: "", age: -5, password: "weak" });
} catch (e) {
if (e instanceof AggregateValidationError) {
console.log(`Found ${e.errors.length} errors:`);
for (const err of e.errors) {
console.log(` [${err.field}] ${err.message}`);
}
}
}

Output:

Found 5 errors:
[name] Name is required
[email] Email is required
[age] Age must be a number between 0 and 150
[password] Password must be at least 8 characters
[password] Password must contain an uppercase letter

Using the Built-In AggregateError

JavaScript has a built-in AggregateError (introduced with Promise.any in ES2021) that you can use directly:

function validateConfig(config) {
const errors = [];

if (!config.host) {
errors.push(new Error("host is required"));
}
if (!config.port) {
errors.push(new Error("port is required"));
} else if (config.port < 1 || config.port > 65535) {
errors.push(new RangeError("port must be between 1 and 65535"));
}
if (!config.database) {
errors.push(new Error("database name is required"));
}

if (errors.length > 0) {
throw new AggregateError(errors, "Invalid database configuration");
}

return config;
}

try {
validateConfig({ port: 99999 });
} catch (e) {
if (e instanceof AggregateError) {
console.log(e.message); // "Invalid database configuration"
for (const err of e.errors) {
console.log(` - ${err.message}`);
}
}
}

Output:

Invalid database configuration
- host is required
- port must be between 1 and 65535
- database name is required

Grouping Errors by Field

For form validation, grouping errors by field is often more useful than a flat list:

class FormValidationError extends Error {
constructor(fieldErrors) {
const fieldCount = Object.keys(fieldErrors).length;
const errorCount = Object.values(fieldErrors).reduce(
(sum, arr) => sum + arr.length, 0
);

super(`${errorCount} error(s) in ${fieldCount} field(s)`);
this.name = "FormValidationError";
this.fieldErrors = fieldErrors;
}

getFieldErrors(field) {
return this.fieldErrors[field] || [];
}

hasFieldError(field) {
return field in this.fieldErrors;
}

toJSON() {
return {
name: this.name,
message: this.message,
fields: this.fieldErrors
};
}
}

function validateForm(data) {
const fieldErrors = {};

function addError(field, message) {
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(message);
}

// Name validation
if (!data.name) {
addError("name", "Name is required");
} else if (data.name.length < 2) {
addError("name", "Name must be at least 2 characters");
}

// Email validation
if (!data.email) {
addError("email", "Email is required");
} else {
if (!data.email.includes("@")) {
addError("email", "Must contain @");
}
if (!data.email.includes(".")) {
addError("email", "Must contain a domain");
}
}

// Password validation
if (data.password) {
if (data.password.length < 8) {
addError("password", "Must be at least 8 characters");
}
if (!/[A-Z]/.test(data.password)) {
addError("password", "Must contain an uppercase letter");
}
if (!/[0-9]/.test(data.password)) {
addError("password", "Must contain a digit");
}
}

if (Object.keys(fieldErrors).length > 0) {
throw new FormValidationError(fieldErrors);
}

return data;
}

try {
validateForm({
name: "A",
email: "invalid",
password: "short"
});
} catch (e) {
if (e instanceof FormValidationError) {
console.log(JSON.stringify(e.toJSON(), null, 2));
}
}

Output:

{
"name": "FormValidationError",
"message": "5 error(s) in 3 field(s)",
"fields": {
"name": ["Name must be at least 2 characters"],
"email": ["Must contain @", "Must contain a domain"],
"password": [
"Must be at least 8 characters",
"Must contain an uppercase letter",
"Must contain a digit"
]
}
}

This structure maps directly to UI error displays, where each form field shows its own list of validation messages.

Combining Aggregation with the Wrapper Pattern

In complex systems, aggregated errors from one layer get wrapped by the layer above:

async function createUser(userData) {
// Validate input (may throw AggregateValidationError)
try {
validateForm(userData);
} catch (e) {
if (e instanceof FormValidationError) {
throw e; // Let validation errors pass through directly
}
throw new AppError("Unexpected validation failure", { cause: e });
}

// Save to database (may throw DatabaseError)
try {
return await saveToDatabase(userData);
} catch (e) {
throw new AppError("Failed to create user", {
cause: e,
code: "USER_CREATION_FAILED"
});
}
}

// The API handler deals with clean, well-typed errors
try {
await createUser(requestBody);
} catch (e) {
if (e instanceof FormValidationError) {
return { status: 400, body: e.toJSON() };
}
if (e instanceof AppError) {
console.error("Service error:", e.message, "Cause:", e.cause);
return { status: 500, body: { error: e.code } };
}
// Truly unexpected
console.error("Unknown error:", e);
return { status: 500, body: { error: "INTERNAL_ERROR" } };
}

Summary

Custom errors transform error handling from a guessing game into a structured, type-safe system. By building a hierarchy of error classes with meaningful names, codes, and context, you make your application easier to debug, maintain, and monitor.

ConceptKey Point
class MyError extends ErrorCreate custom errors by extending Error
super(message)Must be called first in the constructor to initialize message and stack
this.nameSet to match the class name, or use this.constructor.name for automatic naming
Error.captureStackTraceV8-specific method for cleaner stack traces (exclude constructor from trace)
cause property (ES2022)Pass { cause: originalError } to super() to chain errors
Wrapper patternCatch low-level errors, wrap in higher-level errors with context
instanceof checkingCheck from most specific to most general in catch blocks
Error aggregationCollect multiple errors and throw them together for batch validation
AggregateErrorBuilt-in class (ES2021) for holding multiple error objects
toJSON() methodAdd to custom errors for reliable serialization to logging services
isOperational flagDistinguish expected errors from programming bugs

Key rules to remember:

  • Always call super(message) first in custom error constructors
  • Use this.constructor.name in a base class to auto-set name for all subclasses
  • Add domain-specific properties (code, statusCode, field, details) that enable precise handling
  • Use the cause option to chain errors and preserve the original root cause
  • Order instanceof checks from most specific to most general
  • Aggregate validation errors so users see all problems at once
  • Implement toJSON() on custom errors for logging and API responses