ZZuro Docs

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-handler

Adds 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 + errorHandler
  • src/lib/errors.ts: reusable typed error classes (BadRequestError, NotFoundError, etc.).
  • src/middleware/error-handler.ts: asyncHandler, notFoundHandler, and global errorHandler.
  • src/app.ts: registers app.use(notFoundHandler) and app.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/123

Example response:

{
  "status": "error",
  "code": "NOT_FOUND",
  "message": "User not found"
}

How It Works

  1. Request hits a route wrapped by asyncHandler.
  2. Route throws AppError / ZodError (or an unknown error).
  3. Global errorHandler normalizes the error.
  4. Response is returned as JSON (status, code, message, optional errors/requestId).

Configuration

# Required: none
# Optional (from core):
NODE_ENV=development
  • NODE_ENV: includes stack traces in development and hides them in production.

API Reference

  • asyncHandler(fn): wraps async route handlers and forwards rejected promises to next().
  • errorHandler(err, req, res, next): global JSON formatter (422 for ZodError, typed status/code for AppError, 500 fallback).
  • notFoundHandler(req, res): catches unmatched routes and returns 404 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_abc123

If 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

ClassHTTP StatusCode
BadRequestError400BAD_REQUEST
UnauthorizedError401UNAUTHORIZED
ForbiddenError403FORBIDDEN
NotFoundError404NOT_FOUND
ConflictError409CONFLICT
ValidationError422VALIDATION_ERROR
InternalServerError500INTERNAL_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).

On this page