javascript-today

Error Handling Patterns

Proper error handling makes your application reliable and helps debug issues quickly. Express provides several ways to handle errors gracefully.

Basic Error Handling

Try-Catch for Synchronous Errors

const express = require('express');
const app = express();

app.get('/api/users/:id', (req, res) => {
  try {
    const id = parseInt(req.params.id);
    
    if (isNaN(id)) {
      throw new Error('Invalid user ID');
    }
    
    // Simulate error
    if (id > 100) {
      throw new Error('User not found');
    }
    
    res.json({ id, name: 'User' });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Problems with Basic Approach

// ❌ Repetitive error handling
app.get('/route1', (req, res) => {
  try {
    // ... logic
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.get('/route2', (req, res) => {
  try {
    // ... logic
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

Express Error Handler Middleware

Centralized error handling with 4-parameter middleware:

const express = require('express');
const app = express();

app.use(express.json());

// Routes
app.get('/api/users/:id', (req, res, next) => {
  const id = parseInt(req.params.id);
  
  if (isNaN(id)) {
    const error = new Error('Invalid ID');
    error.status = 400;
    return next(error); // Pass to error handler
  }
  
  res.json({ id });
});

// Error handler middleware (must have 4 parameters!)
app.use((err, req, res, next) => {
  console.error('Error:', err.message);
  
  const statusCode = err.status || 500;
  const message = err.message || 'Internal Server Error';
  
  res.status(statusCode).json({
    error: message
  });
});

app.listen(3000);

Key points:

  • Error handler must have 4 parameters: (err, req, res, next)
  • Must be defined after all routes
  • Use next(error) to pass errors to handler

Custom Error Classes

Create specific error types:

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Specific error types:

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
  }
}

class ValidationError extends AppError {
  constructor(message = 'Validation failed') {
    super(message, 400);
  }
}

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

module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError };

Usage:

const { NotFoundError, ValidationError } = require('./errors/AppError');

app.get('/api/users/:id', (req, res, next) => {
  const id = parseInt(req.params.id);
  
  if (isNaN(id)) {
    return next(new ValidationError('Invalid user ID'));
  }
  
  const user = users.find(u => u.id === id);
  
  if (!user) {
    return next(new NotFoundError('User not found'));
  }
  
  res.json(user);
});

// Error handler
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  
  res.status(statusCode).json({
    error: err.message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});

Async Error Handling

Problem with Async/Await

// ❌ Unhandled promise rejection!
app.get('/api/data', async (req, res) => {
  const data = await fetchData(); // If this throws, Express won't catch it
  res.json(data);
});

Solution 1: Try-Catch

app.get('/api/data', async (req, res, next) => {
  try {
    const data = await fetchData();
    res.json(data);
  } catch (error) {
    next(error);
  }
});

Solution 2: Async Handler Wrapper

// utils/asyncHandler.js
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

module.exports = asyncHandler;

Usage:

const asyncHandler = require('./utils/asyncHandler');

app.get('/api/data', asyncHandler(async (req, res) => {
  const data = await fetchData(); // Errors automatically caught
  res.json(data);
}));

app.post('/api/users', asyncHandler(async (req, res) => {
  const user = await createUser(req.body);
  res.status(201).json(user);
}));

Express 5 (Future)

Express 5+ will handle async errors automatically:

// No wrapper needed in Express 5+
app.get('/api/data', async (req, res) => {
  const data = await fetchData();
  res.json(data);
});

Complete Error Handler

Production-ready error handling:

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  // Log error
  console.error('Error occurred:', {
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method
  });
  
  // Default to 500
  let statusCode = err.statusCode || 500;
  let message = err.message;
  
  // Mongoose validation error
  if (err.name === 'ValidationError') {
    statusCode = 400;
    message = Object.values(err.errors).map(e => e.message).join(', ');
  }
  
  // Mongoose cast error (invalid ObjectId)
  if (err.name === 'CastError') {
    statusCode = 400;
    message = 'Invalid ID format';
  }
  
  // MongoDB duplicate key
  if (err.code === 11000) {
    statusCode = 400;
    message = 'Duplicate value entered';
  }
  
  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    statusCode = 401;
    message = 'Invalid token';
  }
  
  if (err.name === 'TokenExpiredError') {
    statusCode = 401;
    message = 'Token expired';
  }
  
  // Response
  res.status(statusCode).json({
    success: false,
    error: message,
    // Show stack trace in development only
    ...(process.env.NODE_ENV === 'development' && {
      stack: err.stack,
      originalError: err
    })
  });
}

module.exports = errorHandler;

404 Handler

Handle routes that don’t exist:

const express = require('express');
const app = express();

// Routes
app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

// 404 handler (after all routes)
app.use((req, res, next) => {
  res.status(404).json({
    error: 'Route not found',
    path: req.url
  });
});

// Or pass to error handler
app.use((req, res, next) => {
  const error = new Error('Route not found');
  error.status = 404;
  next(error);
});

// Error handler
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({
    error: err.message
  });
});

Validation Errors

Handle validation errors consistently:

const { ValidationError } = require('./errors/AppError');

// Validation middleware
function validateUser(req, res, next) {
  const { name, email } = req.body;
  const errors = [];
  
  if (!name || name.trim().length < 2) {
    errors.push('Name must be at least 2 characters');
  }
  
  if (!email || !email.includes('@')) {
    errors.push('Valid email is required');
  }
  
  if (errors.length > 0) {
    return next(new ValidationError(errors.join(', ')));
  }
  
  next();
}

app.post('/api/users', validateUser, asyncHandler(async (req, res) => {
  const user = await createUser(req.body);
  res.status(201).json(user);
}));

Error Response Formats

Development Response

{
  "success": false,
  "error": "User not found",
  "statusCode": 404,
  "stack": "Error: User not found\n    at /app/routes/users.js:15:11...",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "path": "/api/users/999",
  "method": "GET"
}

Production Response

{
  "success": false,
  "error": "User not found",
  "statusCode": 404
}

Implementation:

function errorHandler(err, req, res, next) {
  const isDevelopment = process.env.NODE_ENV === 'development';
  
  const response = {
    success: false,
    error: err.message,
    statusCode: err.statusCode || 500
  };
  
  if (isDevelopment) {
    response.stack = err.stack;
    response.timestamp = new Date().toISOString();
    response.path = req.url;
    response.method = req.method;
  }
  
  res.status(response.statusCode).json(response);
}

Database Error Handling

Example with MongoDB/Mongoose:

const asyncHandler = require('./utils/asyncHandler');
const { NotFoundError } = require('./errors/AppError');

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

app.post('/api/users', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
}));

// Error handler catches Mongoose errors
app.use((err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;
  
  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const message = Object.values(err.errors).map(e => e.message);
    error = new AppError(message, 400);
  }
  
  // Mongoose duplicate key
  if (err.code === 11000) {
    error = new AppError('Duplicate field value', 400);
  }
  
  // Mongoose cast error
  if (err.name === 'CastError') {
    error = new AppError('Invalid ID', 400);
  }
  
  res.status(error.statusCode || 500).json({
    error: error.message || 'Server Error'
  });
});

Complete Example

Full application with error handling:

const express = require('express');
const asyncHandler = require('./utils/asyncHandler');
const { AppError, NotFoundError, ValidationError } = require('./errors/AppError');

const app = express();
app.use(express.json());

// Mock database
const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' }
];

// Routes
app.get('/api/users', asyncHandler(async (req, res) => {
  // Simulate async operation
  const result = await Promise.resolve(users);
  res.json(result);
}));

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const id = parseInt(req.params.id);
  
  if (isNaN(id)) {
    throw new ValidationError('Invalid user ID');
  }
  
  const user = users.find(u => u.id === id);
  
  if (!user) {
    throw new NotFoundError('User not found');
  }
  
  res.json(user);
}));

app.post('/api/users', asyncHandler(async (req, res) => {
  const { name, email } = req.body;
  
  if (!name || !email) {
    throw new ValidationError('Name and email are required');
  }
  
  const newUser = {
    id: users.length + 1,
    name,
    email
  };
  
  users.push(newUser);
  res.status(201).json(newUser);
}));

// Simulate error
app.get('/api/error', (req, res) => {
  throw new Error('Something went wrong!');
});

// 404 handler
app.use((req, res, next) => {
  next(new NotFoundError('Route not found'));
});

// Error handler
app.use((err, req, res, next) => {
  console.error('Error:', err.message);
  
  const statusCode = err.statusCode || 500;
  const isDevelopment = process.env.NODE_ENV === 'development';
  
  res.status(statusCode).json({
    success: false,
    error: err.message,
    ...(isDevelopment && { stack: err.stack })
  });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

Best Practices

DO:

  • Use centralized error handler middleware
  • Create custom error classes
  • Use async handler wrapper
  • Log errors for debugging
  • Return appropriate status codes
  • Hide sensitive error details in production
  • Validate input early

DON’T:

  • Expose stack traces in production
  • Forget to call next(error)
  • Use try-catch everywhere (use wrapper)
  • Return 200 for errors
  • Ignore unhandled promise rejections
  • Expose internal error messages to users

Summary

Pattern Use Case
Error handler middleware Centralized error handling
Custom error classes Specific error types
Async handler wrapper Simplify async error handling
Try-catch Synchronous error handling
next(error) Pass errors to handler
404 handler Handle unknown routes

Error Handler Signature:

app.use((err, req, res, next) => {
  // Must have 4 parameters!
});

Async Handler Pattern:

const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);