
Posted on
Stop Writing Brittle if/else Error Handlers in Node.js
YourerrorHandler.js
is probably a mess. If you're building applications in Node.js with Express, you've likely ended up with a centralerrorHandler
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(errinstanceofBadRequestError){// ...}elseif(errinstanceofAuthError){// ...}elseif(errinstanceofPaymentError){// ...}elseif(errinstanceofAnotherCustomError){// 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 anerrorHandler
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 utilityserializeError
function here, which helps standardize how we log original error details.
// errors/common.tsimport{ZodIssue}from"zod";exportinterfaceIHandleableError{statusCode:number;formatResponse():{message:string;statusCode:number;userDetails?:ZodIssue[]};log():Record<string,any>;}exportfunctionserializeError(error?:unknown){if(errorinstanceofError){// 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,};}returnerror;// 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 aCustomError
class that implements our new interface. This will serve as a convenient base for most of our other errors.
// errors/CustomError.tsimport{IHandleableError,serializeError}from"./common";exportclassCustomErrorextendsErrorimplementsIHandleableError{constructor(publicmessage:string,publicstatusCode:number,publicoriginalError: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 errorsname: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, aBadRequestError
can format its response to include detailed validation errors from a library like Zod.
// errors/BadRequestError.tsimport{ZodIssue}from"zod";import{CustomError}from"./CustomError";exportclassBadRequestErrorextendsCustomError{constructor(message:string,publicerrors?:ZodIssue[]){super(message,400,null);// 400 is the standard status code for Bad Request}// Override to provide specific, user-safe detailsformatResponse():{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 detailsvalidationErrors:this.errors,// ... additional logging details specific to BadRequestError};}}
And for sensitive errors likeAuthError
, you can ensure that internal details like theoriginalError
are not logged for security reasons:
// errors/AuthError.tsimport{CustomError}from"./CustomError";exportclassAuthErrorextendsCustomError{constructor(message:string,publicoriginalError?: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, SmartererrorHandler
This is the payoff. Our finalerrorHandler
is now incredibly simple and powerful. It doesn't know aboutBadRequestError
orAuthError
; it only knows about theIHandleableError
contract.
// middleware/errorHandler.tsimport{Request,Response,NextFunction}from"express";import{formatErrorResponse}from"./responseFormatter";// This is your provided utilityimport{logger}from"@/utils/logger";// Assuming you have a logger utilityimport{IHandleableError}from"@/errors/common";exportconsterrorHandler=(err:unknown,req:Request,res:Response,next:NextFunction)=>{// Basic request context for consistent loggingconstlogBase={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(errinstanceofError&&"formatResponse"inerr&&"log"inerr&&"statusCode"inerr){consthandleableError=errasIHandleableError;// Asserting the type for convenience// Differentiate logging for client (4xx) vs. server (5xx) errorsif(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 errorsconstunknownError=errinstanceofError?err:newError("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)exportfunctionformatResponse<T>(data:T,pagination?:Pagination,):SuccessResponse<T>{return{success:true,data,pagination,};}// Error response formatterexportfunctionformatErrorResponse({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 implementIHandleableError
, and you willnever 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)
For further actions, you may consider blocking this person and/orreporting abuse