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
AppErrorhierarchy for all domain errors - All async controllers must use
asyncHandlerto forward errors - The global error middleware is the only place that sends error responses
- Always handle
unhandledRejectionanduncaughtExceptionat 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 |