Error Handler
Published Oct 7, 2025 | Updated Mar 13, 2026
Add centralized API error handling with consistent JSON responses
Overview
Add production-ready error handling and consistent API error responses to your backend.
Install
npx zuro add error-handlerAdds typed error classes, async route wrapper, global error middleware, and auto-wires handlers in app.ts.
What This Generates
src/
├ lib/
│ └ errors.ts
├ middleware/
│ └ error-handler.ts
└ app.ts # updated to register notFoundHandler + errorHandlersrc/lib/errors.ts: reusable typed error classes (BadRequestError,NotFoundError, etc.).src/middleware/error-handler.ts:asyncHandler,notFoundHandler, and globalerrorHandler.src/app.ts: registersapp.use(notFoundHandler)andapp.use(errorHandler)as last middleware.
Quick Example
import { Router } from "express";
import { NotFoundError } from "../lib/errors";
import { asyncHandler } from "../middleware/error-handler";
const router = Router();
router.get("/users/:id", asyncHandler(async (req, res) => {
const user = null;
if (!user) {
throw new NotFoundError("User not found");
}
res.json({ user });
}));Example request:
GET /api/users/123Example response:
{
"status": "error",
"code": "NOT_FOUND",
"message": "User not found"
}How It Works
- Request hits a route wrapped by
asyncHandler. - Route throws
AppError/ZodError(or an unknown error). - Global
errorHandlernormalizes the error. - Response is returned as JSON (
status,code,message, optionalerrors/requestId).
Configuration
# Required: none
# Optional (from core):
NODE_ENV=developmentNODE_ENV: includes stack traces in development and hides them in production.
API Reference
asyncHandler(fn): wraps async route handlers and forwards rejected promises tonext().errorHandler(err, req, res, next): global JSON formatter (422forZodError, typed status/code forAppError,500fallback).notFoundHandler(req, res): catches unmatched routes and returns404 NOT_FOUND.AppError: base class for custom operational errors.BadRequestError,UnauthorizedError,ForbiddenError,NotFoundError,ConflictError,ValidationError,InternalServerError: ready-to-use typed errors.
Advanced Usage
Custom domain error:
import { AppError } from "../lib/errors";
export class PaymentRequiredError extends AppError {
constructor(message = "Payment required") {
super(message, 402, "PAYMENT_REQUIRED");
}
}Use request IDs in responses/log correlation:
GET /api/users/123
x-request-id: req_abc123If present, requestId is included in error responses.
Zod integration (automatic 422):
{
"status": "error",
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [{ "path": "body.email", "message": "Invalid email" }]
}Example Use Cases
- Normalize error payloads across all API endpoints.
- Protect async routes from unhandled promise rejections.
- Map business/domain errors to explicit HTTP status codes.
- Correlate logs and client errors with request IDs.
All Error Classes
| Class | HTTP Status | Code |
|---|---|---|
BadRequestError | 400 | BAD_REQUEST |
UnauthorizedError | 401 | UNAUTHORIZED |
ForbiddenError | 403 | FORBIDDEN |
NotFoundError | 404 | NOT_FOUND |
ConflictError | 409 | CONFLICT |
ValidationError | 422 | VALIDATION_ERROR |
InternalServerError | 500 | INTERNAL_SERVER_ERROR |
All extend AppError. Throw any of them from a route and errorHandler returns the correct status automatically.
Integration with Validator
When you use the validator module alongside error-handler, Zod validation failures are automatically caught and formatted:
router.post("/users", validate(createUserSchema), asyncHandler(async (req, res) => {
// If validation fails, errorHandler returns 422 automatically
res.status(201).json({ user: req.body });
}));The response on a bad request:
{
"status": "error",
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "path": "body.email", "message": "Invalid email" }
]
}Error Response Shape
Every error from errorHandler follows this consistent shape:
{
status: "error",
code: string, // e.g. "NOT_FOUND", "UNAUTHORIZED"
message: string, // human-readable message
errors?: Array<{ // only on validation errors
path: string,
message: string
}>,
requestId?: string // included when x-request-id header is present
}This consistency means your frontend only needs one error handler:
async function apiFetch(url: string) {
const res = await fetch(url);
if (!res.ok) {
const err = await res.json();
throw new Error(`${err.code}: ${err.message}`);
}
return res.json();
}Troubleshooting
Async errors not being caught
Without asyncHandler, rejected promises bypass Express error handling in Express 4:
// ❌ Unhandled rejection in Express 4
router.get("/data", async (req, res) => {
throw new Error("oops"); // crashes the process
});
// ✅ Correct
router.get("/data", asyncHandler(async (req, res) => {
throw new Error("oops"); // forwarded to errorHandler
}));Note: Express 5 (when stable) handles async errors natively. asyncHandler is still safe to use with Express 5.
Stack traces appearing in production responses
Set NODE_ENV=production in your .env. The error handler hides stack traces automatically when not in development mode.
Custom errors not returning expected status code
Ensure your custom error extends AppError and passes the correct HTTP status as the second argument:
export class PaymentRequiredError extends AppError {
constructor(message = "Payment required") {
super(message, 402, "PAYMENT_REQUIRED"); // status is 2nd arg
}
}404 handler not triggering
The notFoundHandler must be registered after all routes in app.ts. Zuro does this automatically — if you've added routes manually, ensure they're mounted before app.use(notFoundHandler).