Like Blockend? Give it a ⭐ on GitHub.

Star
blockend

Blockend

02 blocks

Error Handler

Centralized, type-safe error handling pipeline for Express applications with a typed AppError class and error catalog.

The Error Handler block gives your Express application a single, predictable place to handle every error — expected or not.

It separates errors you anticipate (bad input, missing resources, failed auth) from bugs you don't, returning a clean JSON response for the former and a safe, logged fallback for the latter.


Features

  • Centralized Express error-handling middleware
  • Typed AppError class with status code and operational flag
  • Shared error catalog for consistent messages across your app
  • Built-in Zod validation error formatting
  • Async route wrapper so rejected promises never get swallowed
  • Zero database assumptions
  • TypeScript support

File Structure

error-handler
├── app-error.ts
├── errors.ts
├── http-status.ts
├── throw-error.ts
├── async-handler.ts
├── global-error-handler.ts
└── index.ts
  • app-error.ts — Base AppError class that all expected application errors extend.
  • errors.ts — Shared error catalog (ERRORS) with consistent message and HTTP status mappings.
  • http-status.ts — Named HTTP status code constants.
  • throw-error.ts — Utility to throw an AppError from a catalog entry.
  • async-handler.ts — Wraps async route handlers so rejected promises reach the error handler.
  • global-error-handler.ts — Express 4-argument error-handling middleware.
  • index.ts — Public exports.

Installation

pnpm dlx blockend-cli add error-handler

Detect Configuration

Blockend reads your project's language, framework, and alias setup.

Install Dependencies

zod is required for validation error formatting. Blockend installs it automatically if it isn't already present.

Generate Files

The block is generated inside your configured blocks directory.

Copy the files below into your project's blocks directory.

Peer Dependencies

DependencyRequired for
expressExpress middleware
zodValidation error formatting
@types/expressTypeScript types (dev)

blocks/error-handler/app-error.ts

Base class for all expected, handled application errors.

/**
 * Base class for all expected, handled application errors.
 *
 * Use this for errors you anticipate — bad input, missing resources,
 * auth failures. The global error handler checks `instanceof AppError`
 * and returns a clean, predictable JSON shape for these.
 *
 * Anything that is NOT an AppError is treated as a bug, logged in full,
 * and never leaked to the client beyond a generic message.
 */
export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    /**
     * Operational errors are expected and safe to expose to the client.
     * Set to false for errors that are technically anticipated but
     * shouldn't reveal details (rare — defaults to true for normal use).
     */
    public readonly isOperational: boolean = true
  ) {
    super(message);
    this.name = "AppError";

    // Required when extending built-ins like Error in TypeScript —
    // without this, `instanceof AppError` can silently return false
    // depending on the consuming project's compile target.
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

blocks/error-handler/http-status.ts

Named HTTP status code constants.

export const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  CONFLICT: 409,
  UNPROCESSABLE_ENTITY: 422,
  INTERNAL_SERVER_ERROR: 500,
  SERVICE_UNAVAILABLE: 503
} as const;

export type HttpStatus = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];

blocks/error-handler/errors.ts

Common error catalog. Use with throwError(ERRORS.NOT_FOUND) or reference .message / .status directly.

import { HTTP_STATUS } from "./http-status.js";

/**
 * Common error catalog. Use with `throwError(ERRORS.NOT_FOUND)` or
 * reference `.message` / `.status` directly when building your own AppError.
 *
 * This is a starting set, not an exhaustive one — add your own entries
 * as your domain needs them. It's your file now.
 */
export const ERRORS = {
  VALIDATION_FAILED: {
    message: "Validation failed",
    status: HTTP_STATUS.BAD_REQUEST
  },
  BAD_REQUEST: {
    message: "Bad request",
    status: HTTP_STATUS.BAD_REQUEST
  },
  INVALID_CREDENTIALS: {
    message: "Invalid credentials",
    status: HTTP_STATUS.UNAUTHORIZED
  },
  UNAUTHORIZED: {
    message: "Unauthorized access",
    status: HTTP_STATUS.UNAUTHORIZED
  },
  FORBIDDEN: {
    message: "You do not have permission to perform this action",
    status: HTTP_STATUS.FORBIDDEN
  },
  TOKEN_EXPIRED: {
    message: "Session expired. Please log in again.",
    status: HTTP_STATUS.UNAUTHORIZED
  },
  INVALID_TOKEN: {
    message: "Invalid token",
    status: HTTP_STATUS.UNAUTHORIZED
  },
  TOKEN_TYPE_MISMATCH: {
    message: "Token type mismatch",
    status: HTTP_STATUS.UNAUTHORIZED
  },
  TOKEN_REVOKED: {
    message: "Token has been revoked",
    status: HTTP_STATUS.UNAUTHORIZED
  },
  NOT_FOUND: {
    message: "Not found",
    status: HTTP_STATUS.NOT_FOUND
  },
  USER_ALREADY_EXISTS: {
    message: "User already exists",
    status: HTTP_STATUS.CONFLICT
  },
  DUPLICATE_RESOURCE: {
    message: "Resource already exists",
    status: HTTP_STATUS.CONFLICT
  },
  UNPROCESSABLE: {
    message: "Unable to process the request",
    status: HTTP_STATUS.UNPROCESSABLE_ENTITY
  },
  INTERNAL_SERVER_ERROR: {
    message: "Internal server error",
    status: HTTP_STATUS.INTERNAL_SERVER_ERROR
  },
  SERVICE_UNAVAILABLE: {
    message: "Service temporarily unavailable",
    status: HTTP_STATUS.SERVICE_UNAVAILABLE
  }
} as const;

export type ErrorKey = keyof typeof ERRORS;

blocks/error-handler/throw-error.ts

Throws an AppError from a catalog entry.

import { AppError } from "./app-error.js";
import { ERRORS } from "./errors.js";

/**
 * Throws an AppError from a catalog entry in ERRORS.
 *
 * Usage: throwError(ERRORS.NOT_FOUND)
 * Equivalent to: throw new AppError(404, 'Not found')
 *
 * Use this for catalog errors. Use `new AppError(status, message)`
 * directly for one-off errors that don't belong in the shared catalog.
 */
export function throwError(error: { message: string; status: number }): never {
  throw new AppError(error.status, error.message);
}

export { ERRORS };

blocks/error-handler/async-handler.ts

Wraps an async Express route handler so rejected promises are passed to next().

import type { Request, Response, NextFunction } from "express";

/**
 * Wraps an async Express route handler so rejected promises are passed
 * to `next()` and reach the global error handler, instead of crashing
 * the process or hanging the request.
 *
 * Pairs directly with globalErrorHandler — use both together.
 *
 * router.get('/users/:id', asyncHandler(async (req, res) => {
 *   const user = await db.user.findUnique({ where: { id: req.params.id } })
 *   if (!user) throwError(ERRORS.NOT_FOUND)
 *   res.json(user)
 * }))
 */
export const asyncHandler =
  (fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>) =>
  (req: Request, res: Response, next: NextFunction): void => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };

blocks/error-handler/global-error-handler.ts

Express error-handling middleware. Register this last, after all routes.

import type { Request, Response, NextFunction } from "express";
import { z, ZodError } from "zod";

import { AppError } from "./app-error.js";
import { HTTP_STATUS } from "./http-status.js";
import { ERRORS } from "./errors.js";

/**
 * Express error-handling middleware. Register this LAST, after all routes
 * and other middleware — Express identifies error handlers by their
 * 4-argument signature, and only calls the first one that matches.
 *
 * app.use(globalErrorHandler)
 */
export const globalErrorHandler = (
  err: unknown,
  req: Request,
  res: Response,
  // oxlint-disable-next-line @typescript-eslint/no-unused-vars
  next: NextFunction
) => {
  // -------------------------
  // Custom AppError — expected, operational errors
  // -------------------------
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      data: null,
      message: err.message
    });
  }

  // -------------------------
  // Zod validation error
  // -------------------------
  if (err instanceof ZodError) {
    return res.status(HTTP_STATUS.BAD_REQUEST).json({
      success: false,
      data: null,
      message: ERRORS.VALIDATION_FAILED.message,
      errors: z.treeifyError(err)
    });
  }

  // -------------------------
  // Unknown / unhandled errors — never leak details to the client
  // -------------------------
  logUnhandledError(err, req);

  return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
    success: false,
    data: null,
    message: ERRORS.INTERNAL_SERVER_ERROR.message
  });
};

/**
 * Single seam for unhandled-error logging. Swap this out for a
 * structured logger (e.g. Blockend's `logger` block) without touching
 * the control flow above.
 */
function logUnhandledError(err: unknown, req: Request): void {
  // oxlint-disable-next-line no-console
  console.error("[UNHANDLED ERROR]", {
    method: req.method,
    path: req.path,
    error: err
  });
}

blocks/error-handler/index.ts

Public exports — re-exports all public APIs.

export { AppError } from "./app-error.js";
export { ERRORS } from "./errors.js";
export type { ErrorKey } from "./errors.js";
export { HTTP_STATUS } from "./http-status.js";
export type { HttpStatus } from "./http-status.js";
export { throwError } from "./throw-error.js";
export { globalErrorHandler } from "./global-error-handler.js";
export { asyncHandler } from "./async-handler.js";

Configuration

ExportTypeDescription
AppErrorclassBase error class — new AppError(statusCode, message, isOperational?)
ERRORSRecord<string, { message: string; status: number }>Shared catalog of common errors
HTTP_STATUSRecord<string, number>Named HTTP status code constants
throwError(error: { message, status }) => neverThrows an AppError from a catalog entry
globalErrorHandlerExpress ErrorRequestHandlerThe 4-argument middleware — register last
asyncHandler(fn) => RequestHandlerWraps async route handlers to forward rejections to next()

Architecture

Request


Route Handler (optionally wrapped with asyncHandler)


Error thrown (AppError / ZodError / unknown)


globalErrorHandler
   ├── AppError     → 4xx JSON response
   ├── ZodError     → 400 JSON with field errors
   └── Unknown      → 500 JSON + server-side log

The globalErrorHandler is registered last in your Express middleware stack. It checks the error type and returns the appropriate response. AppError instances return clean JSON with the configured status code. ZodError instances are formatted using z.treeifyError. All other errors are logged server-side and returned as a generic 500 response.


When to Use

  • You want a centralized place to handle every error in your Express application.
  • You need consistent JSON error responses across your API.
  • You use Zod for validation and want automatic error formatting.

When Not to Use

  • You don't use Express.
  • You prefer handling errors ad-hoc in each route handler.
  • You don't need structured error responses.

Usage

Setup

Register the handler last, after all your routes and middleware:

import express from "express";
import { globalErrorHandler, asyncHandler, throwError, ERRORS } from "@/blocks/error-handler";

const app = express();

app.get(
  "/users/:id",
  asyncHandler(async (req, res) => {
    if (!req.params.id) throwError(ERRORS.BAD_REQUEST);
    const user = await db.user.findUnique({ where: { id: req.params.id } });
    if (!user) throwError(ERRORS.NOT_FOUND);
    res.json(user);
  })
);

app.use(globalErrorHandler); // Must be last
app.listen(3000);

Throwing Errors

Use the catalog for common cases, AppError for one-off errors:

import { throwError, ERRORS, AppError } from "@/blocks/error-handler";

// From the catalog — consistent messages across your API
throwError(ERRORS.NOT_FOUND);

// One-off error with a custom message
throw new AppError(400, `Unsupported plan: ${plan}`);

Validation Errors

Zod errors are automatically formatted when they reach globalErrorHandler:

import { z } from "zod";
import { asyncHandler } from "@/blocks/error-handler";

const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
});

router.post(
  "/users",
  asyncHandler(async (req, res) => {
    const data = createUserSchema.parse(req.body); // throws ZodError on failure
    const user = await db.user.create({ data });
    res.status(201).json(user);
  })
);

Response on failure:

{
  "success": false,
  "data": null,
  "message": "Validation failed",
  "errors": {
    "properties": {
      "email": { "errors": ["Invalid email"] }
    }
  }
}

Mapping Database Errors

Map database-specific errors to AppError in your data layer:

router.post(
  "/users",
  asyncHandler(async (req, res, next) => {
    try {
      const user = await db.user.create({ data: req.body });
      res.status(201).json(user);
    } catch (err) {
      if (isUniqueConstraintError(err)) {
        return next(new AppError(409, "A user with this email already exists"));
      }
      next(err);
    }
  })
);

API Reference

AppError

class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    public readonly isOperational: boolean = true
  );
}
ParameterTypeRequiredDefaultDescription
statusCodenumberYesHTTP status code
messagestringYesError message
isOperationalbooleanNotrueWhether the error is safe to expose to the client

ERRORS

KeyStatusMessage
VALIDATION_FAILED400Validation failed
BAD_REQUEST400Bad request
INVALID_CREDENTIALS401Invalid credentials
UNAUTHORIZED401Unauthorized access
FORBIDDEN403You do not have permission to perform this action
TOKEN_EXPIRED401Session expired. Please log in again.
INVALID_TOKEN401Invalid token
TOKEN_TYPE_MISMATCH401Token type mismatch
TOKEN_REVOKED401Token has been revoked
NOT_FOUND404Not found
USER_ALREADY_EXISTS409User already exists
DUPLICATE_RESOURCE409Resource already exists
UNPROCESSABLE422Unable to process the request
INTERNAL_SERVER_ERROR500Internal server error
SERVICE_UNAVAILABLE503Service temporarily unavailable

throwError

function throwError(error: { message: string; status: number }): never;

Throws an AppError from a catalog entry. Use for catalog errors; use new AppError(status, message) directly for one-off errors.

globalErrorHandler

const globalErrorHandler: (err: unknown, req: Request, res: Response, next: NextFunction) => void;

Express 4-argument error-handling middleware. Register last.

asyncHandler

const asyncHandler: (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
) => (req: Request, res: Response, next: NextFunction) => void;

Wraps async route handlers so rejected promises are forwarded to next().

HTTP_STATUS

const HTTP_STATUS: {
  OK: 200;
  CREATED: 201;
  BAD_REQUEST: 400;
  UNAUTHORIZED: 401;
  FORBIDDEN: 403;
  NOT_FOUND: 404;
  CONFLICT: 409;
  UNPROCESSABLE_ENTITY: 422;
  INTERNAL_SERVER_ERROR: 500;
  SERVICE_UNAVAILABLE: 503;
};

Examples

Extending the Error Catalog

Add domain-specific errors to the shared catalog:

// blocks/error-handler/errors.ts
export const ERRORS = {
  ...existingEntries,
  SUBSCRIPTION_EXPIRED: {
    message: "Your subscription has expired",
    status: HTTP_STATUS.FORBIDDEN
  }
} as const;

  • Logger — Replace the internal console.error call with a structured logger for production error tracking.
  • Response Formatter — Format error responses consistently across your API.

FAQ

What happens when an unhandled error reaches globalErrorHandler?

It logs the error server-side with console.error including the method, path, and error details. The client receives a generic 500 response — no stack traces or internal details are leaked.

How do I add my own error types?

Create entries in the ERRORS catalog or throw new AppError(status, message) directly for one-off errors.

Does this block work with databases?

No. It makes no database assumptions. Map database-specific errors to AppError in your own data layer.

On this page