DEV Community

Cover image for A Clean Way to Refactor Error Handling in Node.js
Mark M
Mark M

Posted on • Edited on • Originally published at Medium

A Clean Way to Refactor Error Handling in Node.js

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!
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }));
};
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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)