# Building a Proper Express Error Handling Layer (Not Just console.log)
By Govind Bajaj · 2024

Tags: express, nodejs, error-handling, backend, api-design
I reviewed 12 Express repos last month. Eleven of them logged errors to console and returned 500. Here's the error handling architecture I use in production.
I reviewed twelve Express.js codebases last month for potential contract work.

Eleven of them handled errors the same way: `console.log(err)` followed by `res.status(500).json({ error: 'Something went wrong' })`. No error classification. No user-friendly messages. No logging to external services. No distinction between operational errors (expected) and programmer errors (bugs).

The twelfth had a proper error handling layer. That's the one I took the contract for.

## The Two Types of Errors

Before writing error handling code, you need to classify errors into two categories:

**Operational errors** are expected failures your application should handle gracefully. A user enters an invalid email. A product is out of stock. A third-party API is temporarily down. These are not bugs — they're normal operating conditions.

**Programmer errors** are bugs. You called a function with the wrong arguments. You tried to read a property of null. You forgot to await a Promise. These should crash the process in development and be logged urgently in production.

This distinction determines everything: HTTP status codes, user-facing messages, logging levels, and alerting urgency.

## The Custom Error Class Hierarchy

I create a base error class and extend it for each operational error type:

```ts
// errors/AppError.ts
export class AppError extends Error {
  public readonly statusCode: number
  public readonly isOperational: boolean
  public readonly code: string

  constructor(
    message: string,
    statusCode: number = 500,
    code: string = 'INTERNAL_ERROR'
  ) {
    super(message)
    this.statusCode = statusCode
    this.isOperational = true
    this.code = code
    Error.captureStackTrace(this, this.constructor)
  }
}

// Specific error types
export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400, 'VALIDATION_ERROR')
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404, 'NOT_FOUND')
  }
}

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

export class ConflictError extends AppError {
  constructor(message: string) {
    super(message, 409, 'CONFLICT')
  }
}
```

Each error type maps to an HTTP status code. Each has a machine-readable code for the frontend to handle programmatically. All are operational — they're expected and handled.

## Using Errors in Route Handlers

```ts
// routes/products.ts
import { Router } from 'express'
import { NotFoundError, ValidationError } from '../errors/AppError'
import { asyncHandler } from '../middleware/asyncHandler'

const router = Router()

router.get('/:id', asyncHandler(async (req, res) => {
  const product = await Product.findById(req.params.id)
  
  if (!product) {
    throw new NotFoundError('Product')
  }
  
  res.json(product)
}))

router.post('/', asyncHandler(async (req, res) => {
  const { name, price } = req.body
  
  if (!name || price === undefined) {
    throw new ValidationError('Name and price are required')
  }
  
  if (price < 0) {
    throw new ValidationError('Price cannot be negative')
  }
  
  const product = await Product.create({ name, price })
  res.status(201).json(product)
}))

export default router
```

The `asyncHandler` wrapper catches rejected Promises and passes them to Express's error handling middleware:

```ts
// middleware/asyncHandler.ts
import { Request, Response, NextFunction } from 'express'

export const asyncHandler = (fn: Function) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next)
  }
}
```

## The Global Error Handler

This is the critical piece. One middleware at the end of your middleware chain that handles every error:

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

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Operational errors: send structured response to client
  if (err instanceof AppError && err.isOperational) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message
      }
    })
  }

  // Programmer errors: log urgently, send generic message
  console.error('UNEXPECTED ERROR:', err)
  // In production, send to Sentry/DataDog here
  
  return res.status(500).json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
    }
  })
}
```

Register it last, after all routes:

```ts
// app.ts
import express from 'express'
import { errorHandler } from './middleware/errorHandler'

const app = express()

// ... routes ...

app.use(errorHandler) // Must be last!
```

## Frontend Integration

The structured error response lets your frontend handle errors programmatically:

```ts
// Frontend error handler
async function apiCall() {
  try {
    const res = await fetch('/api/products/123')
    const data = await res.json()
    
    if (!data.success) {
      switch (data.error.code) {
        case 'NOT_FOUND':
          router.push('/404')
          break
        case 'UNAUTHORIZED':
          auth.logout()
          break
        case 'VALIDATION_ERROR':
          showToast(data.error.message)
          break
        default:
          showToast('Something went wrong. Please try again.')
      }
    }
    
    return data
  } catch (networkError) {
    showToast('Network error. Check your connection.')
  }
}
```

## Takeaways

- Classify errors into operational (expected) and programmer (bugs) — they need different handling
- Create a custom error hierarchy with HTTP status codes and machine-readable error codes
- Use `asyncHandler` to catch Promise rejections in async route handlers
- One global error handler at the end of your middleware chain handles everything
- Never expose stack traces or internal details in production error responses
- Structured error responses let your frontend handle different error types appropriately
- Log programmer errors to an external service (Sentry, DataDog) with full context