Skip to content

Error Handling#

Centralized error handling with proper logging, status codes, and development/production modes.

Global Error Handler#

import { DrizzleQueryError } from "drizzle-orm/errors";
import type { NextFunction, Request, Response } from "express";
import { StatusCodes } from "http-status-codes";
import config from "../config.js";
import logger from "../utils/logger.js";
import { commonResps, getClientIp } from "../utils/utils.js";

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Log to console in dev mode for easy debugging
  if (config.devMode) {
    console.error(err);
  }

  // Log to winston for production monitoring
  logger.error(err.message, {
    endpoint: `${req.method} ${req.originalUrl}`,
    requestor: { ip: getClientIp(req), userAgent: req.get("User-Agent") },
    err,
  });

  let status = StatusCodes.INTERNAL_SERVER_ERROR;
  let resp: Record<string, any> = commonResps.internalError;

  // Handle database constraint violations
  if (err instanceof DrizzleQueryError) {
    if ((err as any).cause?.code === "23505") {
      status = StatusCodes.CONFLICT;
      resp = {
        error: "Database level resource conflict",
        code: "resource_conflict",
      };
    }
  }

  // Include stack trace in development
  if (config.devMode) {
    resp.stack = err.stack;
  }

  res.status(status).json(resp);
}

404 Handler#

import type { Request, Response } from "express";
import { StatusCodes } from "http-status-codes";

export function handler404(req: Request, res: Response) {
  res.status(StatusCodes.NOT_FOUND).json({
    error: "Endpoint not found",
    code: "not_found",
    endpoint: `${req.method} ${req.originalUrl}`,
  });
}

Register Error Handlers#

// ⚠️ Must be registered LAST (after all routes)
app.use(handler404);
app.use(errorHandler);

Status Codes Reference#

import { StatusCodes } from "http-status-codes";

// Success
StatusCodes.OK                  // 200 - Success
StatusCodes.CREATED             // 201 - Resource created

// Client Errors
StatusCodes.BAD_REQUEST         // 400 - Invalid request
StatusCodes.UNAUTHORIZED        // 401 - Not authenticated
StatusCodes.FORBIDDEN           // 403 - Authenticated but no permission
StatusCodes.NOT_FOUND           // 404 - Resource not found
StatusCodes.CONFLICT            // 409 - Resource already exists

// Server Errors
StatusCodes.INTERNAL_SERVER_ERROR // 500 - Server error

Usage in Routes#

import { StatusCodes } from "http-status-codes";
import type { Request, Response, NextFunction } from "express";

router.get("/:id", async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await getUserById(req.params.id);

    if (!user) {
      return res.status(StatusCodes.NOT_FOUND).json({
        error: "User not found",
        code: "user_not_found",
      });
    }

    res.status(StatusCodes.OK).json({ user });
  } catch (err) {
    next(err); // Pass to error handler
  }
});

router.post("/", async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await createUser(req.body);
    res.status(StatusCodes.CREATED).json({ user });
  } catch (err) {
    next(err);
  }
});

Winston Logger Setup#

Installation#

npm install winston

Logger Configuration#

import winston, { transports } from "winston";
import config from "../config.js";

const {
  combine,
  timestamp,
  printf,
  colorize,
  align,
  errors,
  json,
} = winston.format;

// Custom colors
winston.addColors({
  error: "red",
  warn: "yellow",
  info: "green",
  http: "cyan",
  debug: "magenta",
});

// File log format (JSON for production)
const fileLogFormat = combine(
  timestamp(),
  errors({ stack: true }),
  json()
);

// Console log format (human-readable for development)
const consoleLogFormat = combine(
  colorize({ all: true }),
  timestamp({
    format: "YYYY-MM-DD hh:mm:ss.SSS A",
  }),
  align(),
  printf(({ timestamp, level, message, ...meta }) => {
    const metaString = Object.keys(meta).length ? JSON.stringify(meta) : "";
    return `${timestamp} ${level}: ${message} ${metaString}`;
  })
);

const logger = winston.createLogger({
  level: config.devMode ? "debug" : "http",
  transports: [
    new transports.File({
      filename: "logs/error.log",
      level: "error",
      format: fileLogFormat,
    }),
    new transports.File({
      filename: "logs/combined.log",
      format: fileLogFormat,
    }),
    new transports.Console({
      format: consoleLogFormat,
    }),
  ],
  exitOnError: false,
});

export default logger;

Logger Usage#

import logger from "./logger";

// Different log levels
logger.info("Server started on port 3000");
logger.error("Database connection failed");
logger.warn("API rate limit approaching");
logger.debug({ userId: 123, action: "login" });
logger.http("GET /api/users - 200");

// With metadata
logger.error("User creation failed", {
  userId: "123",
  email: "user@example.com",
  error: err.message,
});

Morgan vs Winston#

Morgan = HTTP request logging - Logs every incoming request - Shows method, URL, status code, response time - Integrates with Winston for consistent logging

Winston = Application logging - Core debugging and monitoring - Error tracking - Custom log levels - File and console output

Integration#

import morgan from "morgan";
import logger from "./logger";

app.use(
  morgan(config.devMode ? "dev" : "combined", {
    stream: {
      write: (msg: string) => {
        logger.http(msg.trim());
      },
    },
  })
);

Complete Flow Example#

// 1. Route with error handling
router.post("/users", async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await createUser(req.body);
    res.status(StatusCodes.CREATED).json({ user });
  } catch (err) {
    next(err); // Goes to error handler
  }
});

// 2. Error is caught by global error handler
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  // Logs error with context
  logger.error(err.message, {
    endpoint: `${req.method} ${req.originalUrl}`,
    requestor: { ip: getClientIp(req) },
    err,
  });

  // Returns appropriate response
  res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
    error: "Failed to create user",
    code: "internal_server_error",
  });
}

// 3. Morgan logs the request
// HTTP/1.1 POST /api/users - 500 - 42ms

Common Response Helpers#

export const commonResps = {
  internalError: {
    error: "Failed to complete request due to internal error",
    code: "internal_server_error",
  },
  unauthorized: {
    error: "Authentication required",
    code: "unauthorized",
  },
  forbidden: {
    error: "Insufficient permissions",
    code: "forbidden",
  },
  notFound: {
    error: "Resource not found",
    code: "not_found",
  },
  conflict: {
    error: "Resource already exists",
    code: "resource_conflict",
  },
};

// Usage
res.status(StatusCodes.UNAUTHORIZED).json(commonResps.unauthorized);

Database Error Handling#

import { DrizzleQueryError } from "drizzle-orm/errors";

try {
  await createUser(data);
} catch (err) {
  if (err instanceof DrizzleQueryError) {
    // Unique constraint violation (e.g., duplicate email)
    if ((err as any).cause?.code === "23505") {
      return res.status(StatusCodes.CONFLICT).json({
        error: "User with this email already exists",
        code: "duplicate_email",
      });
    }
  }
  throw err;
}

Best Practices#

  1. Always use try-catch in async route handlers
  2. Pass errors to next() - Let global handler deal with it
  3. Log with context - Include request details, user info
  4. Hide stack traces in production - Only show in dev mode
  5. Use status codes constants - Import from http-status-codes
  6. Consistent error format - Use error code + message
  7. Database-specific errors - Handle unique constraints, foreign keys

Next Steps#