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)