Error Handling

Implement proper error handling with custom error classes and middleware

Error Handling in Express

Proper error handling is crucial for building robust APIs. Express has built-in error handling, but you'll want to customize it for production applications.

⚠️ Error Handler Signature

// Error middleware has 4 parameters (err, req, res, next)
// Express recognizes this signature as an error handler

app.use((err, req, res, next) => {
  // Handle error
});

Basic Error Handling

import express, { Request, Response, NextFunction } from 'express';

const app = express();

// Route that throws an error
app.get('/error', (req, res) => {
  throw new Error('Something went wrong!');
});

// Async route with error
app.get('/async-error', async (req, res, next) => {
  try {
    const data = await someAsyncOperation();
    res.json(data);
  } catch (error) {
    next(error);  // Pass to error handler
  }
});

// Error handling middleware (MUST be last)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({
    error: 'Internal Server Error',
    message: err.message
  });
});

Custom Error Classes

// errors/AppError.ts
export class AppError extends Error {
  statusCode: number;
  isOperational: boolean;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;  // Trusted, expected errors
    
    Error.captureStackTrace(this, this.constructor);
  }
}

// Common error types
export class NotFoundError extends AppError {
  constructor(resource: string = 'Resource') {
    super(`${resource} not found`, 404);
  }
}

export class BadRequestError extends AppError {
  constructor(message: string = 'Bad request') {
    super(message, 400);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = 'Unauthorized') {
    super(message, 401);
  }
}

export class ForbiddenError extends AppError {
  constructor(message: string = 'Forbidden') {
    super(message, 403);
  }
}

export class ValidationError extends AppError {
  errors: any[];
  
  constructor(errors: any[]) {
    super('Validation failed', 400);
    this.errors = errors;
  }
}

Error Handler Middleware

// middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError, ValidationError } from '../errors/AppError';

interface ErrorResponse {
  status: 'error' | 'fail';
  message: string;
  errors?: any[];
  stack?: string;
}

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Log error
  console.error(`[${new Date().toISOString()}] Error:`, {
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method
  });

  // Default values
  let statusCode = 500;
  let response: ErrorResponse = {
    status: 'error',
    message: 'Internal Server Error'
  };

  // Handle known errors
  if (err instanceof AppError) {
    statusCode = err.statusCode;
    response = {
      status: statusCode < 500 ? 'fail' : 'error',
      message: err.message
    };
    
    if (err instanceof ValidationError) {
      response.errors = err.errors;
    }
  }
  
  // Handle specific error types
  if (err.name === 'JsonWebTokenError') {
    statusCode = 401;
    response.message = 'Invalid token';
  }
  
  if (err.name === 'TokenExpiredError') {
    statusCode = 401;
    response.message = 'Token expired';
  }

  // Include stack in development
  if (process.env.NODE_ENV === 'development') {
    response.stack = err.stack;
  }

  res.status(statusCode).json(response);
};

// 404 handler
export const notFoundHandler = (req: Request, res: Response) => {
  res.status(404).json({
    status: 'fail',
    message: `Route ${req.method} ${req.path} not found`
  });
};

Async Error Wrapper

// utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';

type AsyncHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => Promise;

// Wrap async functions to catch errors automatically
export const asyncHandler = (fn: AsyncHandler) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

// Usage - no try/catch needed!
import { asyncHandler } from './utils/asyncHandler';
import { NotFoundError } from './errors/AppError';

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    throw new NotFoundError('User');
  }
  
  res.json(user);
}));

// Alternative: express-async-errors package
// Just import it once at the top of your app
import 'express-async-errors';

// Now async errors are caught automatically
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
});

Complete Error Handling Setup

// app.ts
import express from 'express';
import 'express-async-errors'; // Auto-catch async errors
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
import { NotFoundError, BadRequestError } from './errors/AppError';
import userRoutes from './routes/users';

const app = express();

app.use(express.json());

// Routes
app.use('/api/users', userRoutes);

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

// 404 handler for unmatched routes
app.use(notFoundHandler);

// Global error handler (MUST BE LAST)
app.use(errorHandler);

export default app;

// routes/users.ts
import { Router } from 'express';
import { NotFoundError, BadRequestError } from '../errors/AppError';

const router = Router();

router.get('/:id', async (req, res) => {
  const { id } = req.params;
  
  if (!isValidId(id)) {
    throw new BadRequestError('Invalid user ID format');
  }
  
  const user = await User.findById(id);
  
  if (!user) {
    throw new NotFoundError('User');
  }
  
  res.json(user);
});

router.post('/', async (req, res) => {
  const { email } = req.body;
  
  const existing = await User.findByEmail(email);
  if (existing) {
    throw new BadRequestError('Email already in use');
  }
  
  const user = await User.create(req.body);
  res.status(201).json(user);
});

export default router;

Production Error Handling

// Unhandled rejection handler
process.on('unhandledRejection', (reason: Error) => {
  console.error('Unhandled Rejection:', reason);
  // Log to error tracking service
  // Sentry.captureException(reason);
});

// Uncaught exception handler
process.on('uncaughtException', (error: Error) => {
  console.error('Uncaught Exception:', error);
  // Log to error tracking service
  // Sentry.captureException(error);
  
  // Graceful shutdown
  process.exit(1);
});

// Graceful shutdown
const server = app.listen(PORT);

process.on('SIGTERM', () => {
  console.log('SIGTERM received. Shutting down gracefully...');
  server.close(() => {
    console.log('Process terminated.');
    process.exit(0);
  });
});

✅ Error Handling Best Practices

  • • Create custom error classes for different error types
  • • Use async wrapper or express-async-errors
  • • Error handler middleware must be last
  • • Don't expose stack traces in production
  • • Log errors with context (path, method, user)
  • • Use error tracking (Sentry, LogRocket, etc.)
  • • Handle unhandledRejection and uncaughtException