Skip to content

Node.js – Error Handling

Consistent error handling is what separates a maintainable API from a chaotic one. Every error at Cygnus Dynamics flows through one central path: thrown as a typed domain error → caught by asyncHandler → formatted and sent by the global error middleware.

Core Rules

  • Never expose stack traces to API responses in production
  • Never console.error — use the structured logger (pino)
  • Use a typed AppError hierarchy for all domain errors
  • All async controllers must use asyncHandler to forward errors
  • The global error middleware is the only place that sends error responses
  • Always handle unhandledRejection and uncaughtException at process level
  • Log errors once — at the global handler, not at every rethrow

AppError Hierarchy

Define a typed error hierarchy rooted at AppError. This allows the global handler to distinguish between operational errors (expected, safe to expose) and programmer errors (unexpected, must be hidden).

// utils/errors.ts

export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly code: string,
    public readonly isOperational = true
  ) {
    super(message)
    this.name = this.constructor.name
    Error.captureStackTrace(this, this.constructor)
  }
}

// 400 — Bad Request
export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400, 'VALIDATION_ERROR')
  }
}

// 401 — Unauthenticated
export class UnauthorisedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'UNAUTHORISED')
  }
}

// 403 — Authenticated but not permitted
export class ForbiddenError extends AppError {
  constructor(message = 'You do not have permission to perform this action') {
    super(message, 403, 'FORBIDDEN')
  }
}

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

// 409 — Conflict (duplicate resource, state conflict)
export class ConflictError extends AppError {
  constructor(message: string) {
    super(message, 409, 'CONFLICT')
  }
}

// 422 — Unprocessable entity (valid syntax, invalid business state)
export class UnprocessableError extends AppError {
  constructor(message: string) {
    super(message, 422, 'UNPROCESSABLE')
  }
}

// 429 — Rate limited
export class RateLimitError extends AppError {
  constructor(message = 'Too many requests — please try again later') {
    super(message, 429, 'RATE_LIMIT_EXCEEDED')
  }
}

// 503 — External dependency unavailable
export class ServiceUnavailableError extends AppError {
  constructor(service: string) {
    super(`${service} is temporarily unavailable`, 503, 'SERVICE_UNAVAILABLE')
  }
}

Throwing Domain Errors

Throw the most specific error type available. Never throw generic Error for a domain condition.

// ✅ Correct — specific typed errors
export const getOrder = async (orderId: string, requestingUserId: string): Promise<Order> => {
  const order = await ordersRepository.findById(orderId)

  if (!order) {
    throw new NotFoundError(`Order ${orderId}`)     // 404
  }
  if (order.userId !== requestingUserId) {
    throw new ForbiddenError()                       // 403 — don't leak that the order exists
  }
  return order
}

export const cancelOrder = async (orderId: string): Promise<Order> => {
  const order = await ordersRepository.findById(orderId)
  if (!order) throw new NotFoundError(`Order ${orderId}`)

  if (order.status === 'shipped' || order.status === 'delivered') {
    throw new UnprocessableError(                    // 422 — valid request, invalid state
      `Cannot cancel order ${orderId} with status: ${order.status}`
    )
  }
  return ordersRepository.update(orderId, { status: 'cancelled' })
}

export const createOrder = async (userId: string, request: CreateOrderRequest): Promise<Order> => {
  const existing = await ordersRepository.findByReference(request.reference)
  if (existing) {
    throw new ConflictError(                         // 409 — duplicate
      `An order with reference ${request.reference} already exists`
    )
  }
  return ordersRepository.create({ userId, ...request })
}

// ❌ Incorrect — generic error or wrong type
throw new Error('Order not found')                   // no status code, not typed
throw new Error('Not allowed')                       // ambiguous — 401 or 403?
return null                                          // silent failure forces null-check on every caller

Global Error Handler Middleware

The global error handler is the only place that sends error responses. It must be registered last in app.ts.

// middleware/errorHandler.middleware.ts
import type { Request, Response, NextFunction } from 'express'
import { AppError } from '../utils/errors'
import { logger } from '../config/logger'

interface ErrorResponse {
  error: {
    code:    string
    message: string
    details?: unknown
  }
}

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
): void => {
  // Operational errors — expected domain errors, safe to expose
  if (err instanceof AppError) {
    // Log at warn for client errors (4xx), error for server errors (5xx)
    const logLevel = err.statusCode >= 500 ? 'error' : 'warn'
    logger[logLevel]({
      err,
      req: { method: req.method, url: req.url, userId: req.user?.id },
    }, err.message)

    res.status(err.statusCode).json({
      error: {
        code:    err.code,
        message: err.message,
      },
    } satisfies ErrorResponse)
    return
  }

  // Programmer errors — unexpected, hide details from the client
  logger.error({
    err,
    req: { method: req.method, url: req.url, body: req.body },
  }, 'Unexpected error')

  res.status(500).json({
    error: {
      code:    'INTERNAL_ERROR',
      message: 'An unexpected error occurred. Please try again.',
    },
  } satisfies ErrorResponse)
}

Validation Error Handling with Zod

When Zod validation fails, the error must be caught and converted to a structured ValidationError with field-level detail.

// middleware/validate.middleware.ts
import type { Request, Response, NextFunction } from 'express'
import { type ZodSchema, ZodError } from 'zod'
import { ValidationError } from '../utils/errors'

type ValidateTarget = 'body' | 'query' | 'params'

export const validate =
  (schema: ZodSchema, target: ValidateTarget = 'body') =>
  (req: Request, _res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req[target])
    if (!result.success) {
      const fields = result.error.flatten().fieldErrors
      const message = Object.entries(fields)
        .map(([field, errors]) => `${field}: ${errors?.join(', ')}`)
        .join('; ')

      return next(new ValidationError(message))
    }
    req[target] = result.data  // replace with parsed, typed data
    next()
  }

// Usage in routes
router.post(
  '/',
  authenticate,
  validate(createOrderSchema),   // validates req.body against Zod schema
  asyncHandler(ordersController.create)
)

// ✅ Example Zod schema in orders.validator.ts
import { z } from 'zod'

export const createOrderSchema = z.object({
  items: z.array(
    z.object({
      productId: z.string().uuid(),
      quantity:  z.number().int().min(1).max(100),
    })
  ).min(1, 'Order must contain at least one item'),
  shippingAddressId: z.string().uuid(),
  couponCode: z.string().max(20).optional(),
})

export type CreateOrderRequest = z.infer<typeof createOrderSchema>

Consistent Response Shape

All API responses — success and error — use a consistent envelope shape. This makes client-side error handling predictable.

// ✅ Success response
res.status(200).json({
  data: order,
  meta: { total: 42, page: 1, pageSize: 20 },  // for list endpoints
})

res.status(201).json({ data: createdOrder })

res.status(204).send()   // DELETE — no body

// ✅ Error response (sent by global error handler)
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Order abc-123 not found"
  }
}

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "items: Required; shippingAddressId: Invalid uuid"
  }
}

// ❌ Inconsistent shapes — never do this
res.status(400).json({ message: 'bad request' })       // 'message' not 'error'
res.status(404).json('not found')                       // plain string
res.status(500).json({ error: err.stack })             // exposes stack trace
res.status(200).json({ success: false, error: '...' }) // 200 with error body

Process-Level Error Handlers

Register these in server.ts — never in app.ts. They are a last resort safety net, not a replacement for proper error handling.

// server.ts

process.on('unhandledRejection', (reason: unknown) => {
  logger.error({ reason }, 'Unhandled promise rejection — shutting down')
  server.close(() => process.exit(1))
})

process.on('uncaughtException', (error: Error) => {
  logger.error({ error }, 'Uncaught exception — shutting down')
  server.close(() => process.exit(1))
})

unhandledRejection is a symptom, not a strategy

If unhandledRejection fires in production it means an await was not wrapped in try/catch or asyncHandler. Fix the source — do not catch it here to keep the server alive. A process in an unknown state is more dangerous than one that has restarted.

Structured Logging with Pino

Never use console.log or console.error in production code. Use Pino — it outputs structured JSON logs that are searchable in log aggregation tools (Datadog, CloudWatch, ELK).

// config/logger.ts
import pino from 'pino'
import { config } from './index'

export const logger = pino({
  level: config.logLevel,
  ...(config.nodeEnv === 'development' && {
    transport: { target: 'pino-pretty', options: { colorize: true } },
  }),
})

// ✅ Correct — structured logging with context
logger.info({ orderId, userId }, 'Order created')
logger.warn({ orderId, status }, 'Attempted to cancel already-shipped order')
logger.error({ err, orderId }, 'Failed to process payment')

// ❌ Incorrect — unstructured, unsearchable
console.log('Order created:', orderId)
console.error('Error:', err.message)

// ❌ Never log sensitive data
logger.info({ email, password }, 'Login attempt')     // password must never be logged
logger.debug({ cardNumber }, 'Payment details')        // PAN must never be logged

Quick Reference — HTTP Status Codes

Situation Status Error code
Success, returns resource 200 OK
Success, resource created 201 Created
Success, no content 204 No Content
Invalid request body 400 Bad Request VALIDATION_ERROR
Missing / invalid token 401 Unauthorized UNAUTHORISED
Valid token, no permission 403 Forbidden FORBIDDEN
Resource not found 404 Not Found NOT_FOUND
Duplicate resource 409 Conflict CONFLICT
Valid request, invalid state 422 Unprocessable UNPROCESSABLE
Too many requests 429 Too Many Requests RATE_LIMIT_EXCEEDED
Unexpected server error 500 Internal Server Error INTERNAL_ERROR
External dep unavailable 503 Service Unavailable SERVICE_UNAVAILABLE