Your errorHandler.js is probably a mess. If you're building applications in Node.js with Express, you've likely ended up with a central errorHandler middleware that looks like a giant, brittle if/else if chain. It starts small, but soon it's a monster you're afraid to touch.
// The "Before" state we all know...
if (err instanceof BadRequestError) {
// ...
} else if (err instanceof AuthError) {
// ...
} else if (err instanceof PaymentError) {
// ...
} else if (err instanceof AnotherCustomError) {
// You have to modify this file every time you add a new error type!
}
This approach is tightly coupled and not scalable. Today, I'll show you how to refactor this into a flexible, type-safe, and polymorphic system that you'll never have to modify again.
The Goal: A Decoupled, Polymorphic Handler
Our goal is to create an errorHandler that doesn't care about the specific class of an error. It only cares that the error conforms to a contract. This allows us to create new error types anywhere in our application without ever touching the central handler.
Step 1: Define the Contract with an Interface
First, we define our contract using a TypeScript interface. This is the single source of truth for what makes an error "handleable" by our system. We'll also define a utility serializeError function here, which helps standardize how we log original error details.
// errors/common.ts
import { ZodIssue } from "zod";
export interface IHandleableError {
statusCode: number;
formatResponse(): { message: string; statusCode: number; userDetails?: ZodIssue[] };
log(): Record<string, any>;
}
export function serializeError(error?: unknown) {
if (error instanceof Error) {
// Prevents issues with circular references in error objects and captures key details.
return {
message: error.message,
type: error.constructor.name,
name: error.name,
stack: error.stack,
};
}
return error; // Return as-is if not an Error instance (e.g., string, number, null)
}
This contract guarantees three things:
-
statusCode:The HTTP status code to send back. -
formatResponse(): A method that returns a user-safe object for the JSON response. -
log(): A method that returns a rich object for internal, server-side logging.
Step 2: Create a Base Error Class
Next, we create a CustomError class that implements our new interface. This will serve as a convenient base for most of our other errors.
// errors/CustomError.ts
import { IHandleableError, serializeError } from "./common";
export class CustomError extends Error implements IHandleableError {
constructor(
public message: string,
public statusCode: number,
public originalError: unknown,
// ... other properties you might need
) {
super(message);
}
formatResponse(): { message: string; statusCode: number; userDetails?: ZodIssue[] } {
return {
message: this.message,
statusCode: this.statusCode,
};
}
log() {
return {
// Common details for logging across all custom errors
name: this.constructor.name,
message: this.message,
statusCode: this.statusCode,
originalError: serializeError(this.originalError),
};
}
}
Step 3: Extend Your Base for Specific Errors
Now you can create specific, meaningful errors by extending the base class.
For example, a BadRequestError can format its response to include detailed validation errors from a library like Zod.
// errors/BadRequestError.ts
import { ZodIssue } from "zod";
import { CustomError } from "./CustomError";
export class BadRequestError extends CustomError {
constructor(message: string, public errors?: ZodIssue[]) {
super(message, 400, null); // 400 is the standard status code for Bad Request
}
// Override to provide specific, user-safe details
formatResponse(): { message: string; statusCode: number; userDetails: ZodIssue[] } {
return {
message: this.message,
statusCode: this.statusCode,
userDetails: this.errors || [], // Safely expose validation errors to the client
};
}
log() {
return {
...super.log(), // Include base log details
validationErrors: this.errors,
// ... additional logging details specific to BadRequestError
};
}
}
And for sensitive errors like AuthError, you can ensure that internal details like the originalError are not logged for security reasons:
// errors/AuthError.ts
import { CustomError } from "./CustomError";
export class AuthError extends CustomError {
constructor(message: string, public originalError?: unknown) {
super(message, 401, originalError); // 401 is the standard status code for Unauthorized
}
formatResponse(): { message: string; statusCode: number; userDetails?: ZodIssue[] } {
return {
message: this.message,
statusCode: this.statusCode,
};
}
log() {
// For security, explicitly override and ensure sensitive originalError is not logged.
return {
message: this.message,
statusCode: this.statusCode,
// Do NOT include originalError here for security
};
}
}
Step 4: The New, Smarter errorHandler
This is the payoff. Our final errorHandler is now incredibly simple and powerful. It doesn't know about BadRequestError or AuthError; it only knows about the IHandleableError contract.
// middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { formatErrorResponse } from "./responseFormatter"; // This is your provided utility
import { logger } from "@/utils/logger"; // Assuming you have a logger utility
import { IHandleableError } from "@/errors/common";
export const errorHandler = (err: unknown, req: Request, res: Response, next: NextFunction) => {
// Basic request context for consistent logging
const logBase = {
method: req.method,
url: req.originalUrl,
user: req.user || null,
};
// This is the only check we need!
// We check for Error instance and duck-type IHandleableError methods/properties.
// This is because interfaces don't exist at runtime in TypeScript.
if (err instanceof Error && "formatResponse" in err && "log" in err && "statusCode" in err) {
const handleableError = err as IHandleableError; // Asserting the type for convenience
// Differentiate logging for client (4xx) vs. server (5xx) errors
if (handleableError.statusCode < 500) {
logger.warn({ message: err.message, ...logBase, ...handleableError.log() });
} else {
logger.error({ message: err.message, ...logBase, ...handleableError.log() });
}
res
.status(handleableError.statusCode)
.json(formatErrorResponse(handleableError.formatResponse()));
return;
}
// Fallback for completely unknown or unhandled errors
const unknownError = err instanceof Error ? err : new Error("Unknown server error occurred.");
logger.error({
...logBase,
message: unknownError.message,
stack: unknownError.stack,
// Add any other details for truly unexpected errors
});
res.status(500).json(formatErrorResponse({ message: "Internal Server Error", statusCode: 500 }));
};
Step 5: The Response Formatter Utility
While not strictly part of the error handling logic, having a consistent way to format API responses, especially errors, is crucial. This utility ensures all your error responses adhere to a unified structure.
// utils/responseFormatter.ts (or wherever you prefer)
import { ErrorResponse, Pagination, SuccessResponse } from "@/types/response.types";
import { ZodIssue } from "zod";
// Generic success response formatter (included for context)
export function formatResponse<T>(
data: T,
pagination?: Pagination,
): SuccessResponse<T> {
return {
success: true,
data,
pagination,
};
}
// Error response formatter
export function formatErrorResponse({
message,
statusCode,
userDetails,
}: {
message: string;
statusCode: number;
userDetails?: ZodIssue[];
}): ErrorResponse {
return {
success: false,
error: {
message,
code: statusCode,
details: userDetails,
},
};
}
And that's it. You can now create dozens of different error types that all implement IHandleableError, and you will never have to touch this file again.
Key Takeaways
This refactored approach gives you a system that is:
- Decoupled: The central handler isn't tied to specific error classes.
- Scalable: Add new error types with zero friction.
- Type-Safe: The interface acts as a compile-time contract, preventing mistakes.
- Maintainable: Your logic is clean, simple, and easy to reason about.
- Secure: Granular control over logging allows you to protect sensitive information on a per-error basis.
Stop fighting with brittle error handlers and adopt a polymorphic, contract-based approach. Your future self will thank you.
Top comments (0)