Error Boundary
ValidatedNormalize application errors, protect client responses, and connect optional logging or reporting across Express, Fastify, and Hono.
Error Boundary
Overview
The Error Boundary block turns thrown values into consistent HTTP errors without tying error rules to a web framework.
Its dependency-free core classifies errors, adds request context, redacts sensitive fields, calls optional observability hooks, and produces the same error envelope as the Response Formatter block.
Features
- Framework-neutral error normalization and classification
- Typed
AppErrorwith codes, categories, severity, causes, metadata, and client-safe details - Generic 500 responses for unknown and non-operational errors
- Recursive redaction for common credential and authorization fields
- Optional structured logger, async reporter, classifier, and serializer interfaces
- Request context for request ID, user ID, method, path, timestamp, and metadata
- Native Express, Fastify, and Hono lifecycle adapters
- Deprecated Express v1 exports for an in-place migration
File structure
error-handler
├── adapters
│ ├── express.ts
│ ├── fastify.ts
│ └── hono.ts
├── core
│ ├── app-error.ts
│ ├── classify.ts
│ ├── context.ts
│ ├── create-error-boundary.ts
│ ├── defaults.ts
│ ├── normalize.ts
│ ├── sanitize.ts
│ └── serialize.ts
├── express
│ └── deprecated v1 compatibility exports
├── types
│ └── index.ts
└── index.ts- core/ contains the framework-neutral lifecycle.
- adapters/ extracts framework context and writes the core result.
- types/ defines configuration, event, response, and extension contracts.
- express/ keeps v1 imports working while applications move to the v2 API.
Installation
pnpm dlx blockend-cli add error-handlerDetect project
Blockend reads the configured framework and block output path.
Install the adapter peer
The core has no runtime dependency. Blockend installs only the selected framework package.
Copy source
Blockend copies the shared core, tests, and the selected adapter into your blocks directory.
Copy the shared files and one framework adapter into your blocks directory.
Peer dependencies
| Package | Required for |
|---|---|
| None | Core |
express and @types/express | Express adapter |
fastify | Fastify adapter |
hono | Hono adapter |
blocks/error-handler/types/index.ts
Public types for errors, context, hooks, serialization, and boundary results.
/** Classification bucket for an error, used to derive status codes and severity. */
export type ErrorCategory =
| "BAD_REQUEST"
| "VALIDATION"
| "AUTHENTICATION"
| "AUTHORIZATION"
| "NOT_FOUND"
| "CONFLICT"
| "RATE_LIMIT"
| "INTERNAL"
| "SERVICE_UNAVAILABLE"
| (string & {});
/** Severity level assigned to a classified error. */
export type ErrorSeverity = "info" | "warning" | "error" | "critical";
/** Options for constructing an {@link AppError}. */
export interface AppErrorOptions {
code: string;
message: string;
statusCode?: number;
category?: ErrorCategory;
severity?: ErrorSeverity;
isOperational?: boolean;
cause?: unknown;
/** Server-side diagnostic data. Never included in the default client response. */
metadata?: Record<string, unknown>;
/** Client-safe structured details for operational errors. */
details?: unknown;
}
/** Partial context supplied by an adapter before enrichment. */
export interface ErrorContextInput {
requestId?: string;
userId?: string;
path?: string;
method?: string;
timestamp?: string;
metadata?: Record<string, unknown>;
}
/** Fully resolved error context with a required timestamp. */
export interface ErrorContext extends ErrorContextInput {
timestamp: string;
}
/** Internal representation of an error after classification. */
export interface ClassifiedError {
name: string;
code: string;
message: string;
statusCode: number;
category: ErrorCategory;
severity: ErrorSeverity;
isOperational: boolean;
stack?: string;
cause?: unknown;
metadata?: Record<string, unknown>;
details?: unknown;
}
/** Safe envelope returned to the client. */
export interface ErrorResponse {
/** Matches the error envelope produced by the response-formatter block. */
success: false;
data: null;
error: { message: string; details?: unknown };
requestId?: string;
}
/** Structured event passed to loggers and reporters. */
export interface ErrorEvent {
error: {
name: string;
code: string;
message: string;
statusCode: number;
category: ErrorCategory;
severity: ErrorSeverity;
isOperational: boolean;
stack?: string;
cause?: unknown;
metadata?: Record<string, unknown>;
};
context: ErrorContext;
}
/** Sink for structured error logging. */
export interface ErrorLogger {
/** Compatible with the structured logger block's `logger.error` method. */
error(context: Record<string, unknown>, message?: string): void;
}
/** Sink for forwarding error events to external services. */
export interface ErrorReporter {
report(event: ErrorEvent): void | Promise<void>;
}
/** User-provided function that maps a raw error to {@link AppErrorOptions}. */
export type ErrorClassifier = (error: unknown) => AppErrorOptions | null | undefined;
/** Serializer that converts a classified error into a client-safe envelope. */
export type ErrorSerializer = (error: ClassifiedError, context: ErrorContext) => ErrorResponse;
/** Configuration for {@link createErrorBoundary}. */
export interface CreateErrorBoundaryOptions {
logger?: ErrorLogger;
reporter?: ErrorReporter;
classifier?: ErrorClassifier;
serializer?: ErrorSerializer;
/** Additional case-insensitive key fragments to redact. */
sensitiveKeys?: readonly string[];
}
/** Result returned by {@link ErrorBoundary.handle}. */
export interface ErrorBoundaryResult {
statusCode: number;
body: ErrorResponse;
}
/** Framework-agnostic error boundary that classifies, logs, and serializes errors. */
export interface ErrorBoundary {
handle(error: unknown, context?: ErrorContextInput): Promise<ErrorBoundaryResult>;
}
blocks/error-handler/core/app-error.ts
Defines AppError and its deprecated positional constructor overload.
import type { AppErrorOptions, ErrorCategory, ErrorSeverity } from "../types/index";
import { inferCategory, inferSeverity, resolveStatusCode } from "./defaults";
/** Typed error carrying status, category, and operational metadata. */
export class AppError extends Error {
readonly code: string;
readonly statusCode: number;
readonly category: ErrorCategory;
readonly severity: ErrorSeverity;
readonly isOperational: boolean;
readonly metadata?: Record<string, unknown>;
readonly details?: unknown;
constructor(options: AppErrorOptions);
/** @deprecated Use the options-object constructor. */
constructor(statusCode: number, message: string, isOperational?: boolean);
constructor(
optionsOrStatusCode: AppErrorOptions | number,
legacyMessage?: string,
legacyIsOperational?: boolean
) {
const options: AppErrorOptions =
typeof optionsOrStatusCode === "number"
? {
code: "APP_ERROR",
message: legacyMessage ?? "Application error",
statusCode: optionsOrStatusCode,
...(legacyIsOperational === undefined ? {} : { isOperational: legacyIsOperational })
}
: optionsOrStatusCode;
super(options.message, options.cause === undefined ? undefined : { cause: options.cause });
const statusCode = resolveStatusCode(options.statusCode, options.category);
this.name = "AppError";
this.code = options.code;
this.statusCode = statusCode;
this.category = options.category ?? inferCategory(statusCode);
this.severity = options.severity ?? inferSeverity(statusCode);
this.isOperational = options.isOperational ?? statusCode < 500;
if (options.metadata !== undefined) this.metadata = options.metadata;
if (options.details !== undefined) this.details = options.details;
Object.setPrototypeOf(this, AppError.prototype);
}
}
blocks/error-handler/core/app-error.test.ts
Covers constructor defaults, mappings, metadata, and legacy compatibility.
import { describe, expect, it } from "vitest";
import { AppError } from "./app-error";
describe("AppError", () => {
it("stores production error metadata and infers defaults", () => {
const cause = new Error("query failed");
const error = new AppError({
code: "USER_NOT_FOUND",
message: "User not found",
category: "NOT_FOUND",
cause,
metadata: { userId: "user-1" },
details: { field: "userId" }
});
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({
name: "AppError",
code: "USER_NOT_FOUND",
statusCode: 404,
category: "NOT_FOUND",
severity: "warning",
isOperational: true,
cause,
metadata: { userId: "user-1" },
details: { field: "userId" }
});
});
it("defaults server errors to non-operational", () => {
const error = new AppError({ code: "DATABASE_FAILED", message: "Database failed" });
expect(error.statusCode).toBe(500);
expect(error.category).toBe("INTERNAL");
expect(error.severity).toBe("error");
expect(error.isOperational).toBe(false);
});
it("normalizes invalid status codes to a safe mapped status", () => {
const error = new AppError({
code: "INVALID_STATUS",
message: "Invalid status",
statusCode: 200,
category: "CONFLICT"
});
expect(error.statusCode).toBe(409);
});
it("keeps the deprecated positional constructor working", () => {
const error = new AppError(409, "Already exists", false);
expect(error).toMatchObject({
code: "APP_ERROR",
statusCode: 409,
message: "Already exists",
isOperational: false
});
});
});
blocks/error-handler/core/classify.ts
Classifies AppError, classifier results, and unknown failures.
import type { AppErrorOptions, ClassifiedError, ErrorClassifier } from "../types/index";
import { AppError } from "./app-error";
import { inferCategory, inferSeverity, resolveStatusCode } from "./defaults";
import type { NormalizedInput } from "./normalize";
/** Converts an application error into the boundary's framework-neutral representation. */
function toClassifiedError(error: AppError): ClassifiedError {
return {
name: error.name,
code: error.code,
message: error.message,
statusCode: error.statusCode,
category: error.category,
severity: error.severity,
isOperational: error.isOperational,
...(error.stack === undefined ? {} : { stack: error.stack }),
...(error.cause === undefined ? {} : { cause: error.cause }),
...(error.metadata === undefined ? {} : { metadata: error.metadata }),
...(error.details === undefined ? {} : { details: error.details })
};
}
/** Classifies an unrecognized thrown value as a non-operational internal error. */
function unknownError(input: NormalizedInput): ClassifiedError {
const raw = input.raw as Record<string, unknown> | undefined;
const rawStatus = raw && typeof raw === "object" ? (raw.status ?? raw.statusCode) : undefined;
const statusCode =
typeof rawStatus === "number" &&
Number.isInteger(rawStatus) &&
rawStatus >= 400 &&
rawStatus <= 599
? rawStatus
: 500;
return {
name: input.error.name,
code: "INTERNAL_SERVER_ERROR",
message: input.error.message,
statusCode,
category: statusCode === 500 ? "INTERNAL" : inferCategory(statusCode),
severity: inferSeverity(statusCode),
isOperational: false,
...(input.error.stack === undefined ? {} : { stack: input.error.stack }),
...(input.error.cause === undefined ? {} : { cause: input.error.cause })
};
}
/** Applies safe status, category, and severity defaults to a custom classification. */
function normalizeClassifierResult(options: AppErrorOptions): AppError {
const statusCode = resolveStatusCode(options.statusCode, options.category);
return new AppError({
...options,
statusCode,
category: options.category ?? inferCategory(statusCode),
severity: options.severity ?? inferSeverity(statusCode)
});
}
/** Classify a normalized error using an optional custom classifier, falling back to unknownError. */
export function classifyError(
input: NormalizedInput,
classifier?: ErrorClassifier
): ClassifiedError {
if (input.raw instanceof AppError) return toClassifiedError(input.raw);
if (classifier) {
try {
const result = classifier(input.raw);
if (result) return toClassifiedError(normalizeClassifierResult(result));
} catch {
// An extension failure must not break the boundary.
}
}
return unknownError(input);
}
blocks/error-handler/core/context.ts
Adds a timestamp and preserves optional request context.
import type { ErrorContext, ErrorContextInput } from "../types/index";
/** Fill in default timestamp and drop undefined fields from the context input. */
export function enrichErrorContext(input: ErrorContextInput = {}): ErrorContext {
return {
timestamp: input.timestamp ?? new Date().toISOString(),
...(input.requestId === undefined ? {} : { requestId: input.requestId }),
...(input.userId === undefined ? {} : { userId: input.userId }),
...(input.path === undefined ? {} : { path: input.path }),
...(input.method === undefined ? {} : { method: input.method }),
...(input.metadata === undefined ? {} : { metadata: input.metadata })
};
}
blocks/error-handler/core/create-error-boundary.ts
Runs the error lifecycle and contains failures from optional hooks.
import type {
ClassifiedError,
CreateErrorBoundaryOptions,
ErrorBoundary,
ErrorContext,
ErrorEvent
} from "../types/index";
import { classifyError } from "./classify";
import { enrichErrorContext } from "./context";
import { normalizeError } from "./normalize";
import { sanitizeErrorData } from "./sanitize";
import { serializeErrorResponse } from "./serialize";
/** Builds the sanitized event shape shared by logging and reporting hooks. */
function createEvent(error: ClassifiedError, context: ErrorContext): ErrorEvent {
return {
error: {
name: error.name,
code: error.code,
message: error.message,
statusCode: error.statusCode,
category: error.category,
severity: error.severity,
isOperational: error.isOperational,
...(error.stack === undefined ? {} : { stack: error.stack }),
...(error.cause === undefined ? {} : { cause: error.cause }),
...(error.metadata === undefined ? {} : { metadata: error.metadata })
},
context
};
}
/** Create a framework-agnostic error boundary with optional logger, reporter, and classifier. */
export function createErrorBoundary(options: CreateErrorBoundaryOptions = {}): ErrorBoundary {
const serializer = options.serializer ?? serializeErrorResponse;
const sensitiveKeys = options.sensitiveKeys ?? [];
return {
async handle(error, contextInput = {}) {
const normalized = normalizeError(error);
const classified = classifyError(normalized, options.classifier);
const context = sanitizeErrorData(enrichErrorContext(contextInput), sensitiveKeys);
const safeError = sanitizeErrorData(classified, sensitiveKeys);
const event = sanitizeErrorData(createEvent(safeError, context), sensitiveKeys);
if (options.logger) {
try {
options.logger.error(event as unknown as Record<string, unknown>, "Application error");
} catch {
// Logging must not change the response path.
}
}
if (options.reporter) {
try {
await options.reporter.report(event);
} catch {
// Reporting must not replace the application error.
}
}
let body;
try {
body = serializer(safeError, context);
} catch {
body = serializeErrorResponse(safeError, context);
}
return {
statusCode: safeError.isOperational ? safeError.statusCode : 500,
body
};
}
};
}
blocks/error-handler/core/create-error-boundary.test.ts
Covers classification, safe serialization, redaction, and observability behavior.
import { describe, expect, it, vi } from "vitest";
import { AppError } from "./app-error";
import { createErrorBoundary } from "./create-error-boundary";
describe("createErrorBoundary", () => {
it("serializes operational errors with safe details", async () => {
const boundary = createErrorBoundary();
const result = await boundary.handle(
new AppError({
code: "USER_NOT_FOUND",
message: "User not found",
statusCode: 404,
details: { userId: "user-1" }
}),
{ requestId: "req-1" }
);
expect(result).toEqual({
statusCode: 404,
body: {
success: false,
data: null,
error: { message: "User not found", details: { userId: "user-1" } },
requestId: "req-1"
}
});
});
it("hides unknown and non-operational errors", async () => {
const boundary = createErrorBoundary();
const unknown = await boundary.handle(new Error("postgres://admin:secret@internal/db"));
const programmer = await boundary.handle(
new AppError({
code: "BROKEN_INVARIANT",
message: "Internal file C:/srv/private.ts",
statusCode: 400,
isOperational: false
})
);
for (const result of [unknown, programmer]) {
expect(result.statusCode).toBe(500);
expect(result.body).toEqual({
success: false,
data: null,
error: { message: "Internal server error" }
});
expect(JSON.stringify(result.body)).not.toMatch(/postgres|private\.ts|secret/);
}
});
it("classifies third-party errors without adding a dependency", async () => {
class ValidationFailure extends Error {
issues = [{ path: "email", message: "Invalid email" }];
}
const boundary = createErrorBoundary({
classifier(error) {
if (!(error instanceof ValidationFailure)) return undefined;
return {
code: "VALIDATION_FAILED",
message: "Validation failed",
category: "VALIDATION",
details: error.issues
};
}
});
const result = await boundary.handle(new ValidationFailure());
expect(result.statusCode).toBe(400);
expect(result.body.error).toEqual({
message: "Validation failed",
details: [{ path: "email", message: "Invalid email" }]
});
});
it("adds a timestamp when context is missing", async () => {
const report = vi.fn();
const boundary = createErrorBoundary({ reporter: { report } });
await boundary.handle("not-an-error");
const event = report.mock.calls[0]?.[0];
expect(event?.context.timestamp).toEqual(expect.any(String));
expect(event?.context.requestId).toBeUndefined();
expect(event?.error.code).toBe("INTERNAL_SERVER_ERROR");
});
it("redacts sensitive fields in details and observability events", async () => {
const report = vi.fn();
const boundary = createErrorBoundary({ reporter: { report }, sensitiveKeys: ["privateKey"] });
const circular: Record<string, unknown> = { authorization: "Bearer abc" };
circular.self = circular;
const result = await boundary.handle(
new AppError({
code: "INVALID_INPUT",
message: "Invalid input",
statusCode: 400,
details: {
password: "secret",
accessToken: "token",
privateKey: "key",
circular
},
metadata: { clientSecret: "secret" }
}),
{ metadata: { headers: { authorization: "Bearer abc" } } }
);
expect(result.body.error.details).toEqual({
password: "[REDACTED]",
accessToken: "[REDACTED]",
privateKey: "[REDACTED]",
circular: { authorization: "[REDACTED]", self: "[Circular]" }
});
expect(JSON.stringify(report.mock.calls[0]?.[0])).not.toContain("Bearer abc");
expect(JSON.stringify(report.mock.calls[0]?.[0])).not.toContain('"secret"');
});
it("awaits reporters and ignores observability failures", async () => {
const calls: string[] = [];
const boundary = createErrorBoundary({
logger: {
error() {
calls.push("logger");
throw new Error("logger failed");
}
},
reporter: {
async report() {
await Promise.resolve();
calls.push("reporter");
throw new Error("reporter failed");
}
}
});
const result = await boundary.handle(new Error("boom"));
expect(calls).toEqual(["logger", "reporter"]);
expect(result.statusCode).toBe(500);
});
it("falls back when a custom classifier or serializer throws", async () => {
const boundary = createErrorBoundary({
classifier() {
throw new Error("classifier failed");
},
serializer() {
throw new Error("serializer failed");
}
});
const result = await boundary.handle(new Error("original failure"));
expect(result.body.error.message).toBe("Internal server error");
});
});
blocks/error-handler/core/defaults.ts
Contains category-to-status defaults and category or severity inference.
import type { ErrorCategory, ErrorSeverity } from "../types/index";
/** Sentinel message used for non-operational errors in client responses. */
export const INTERNAL_ERROR_MESSAGE = "Internal server error";
const STATUS_BY_CATEGORY: Readonly<Record<string, number>> = {
BAD_REQUEST: 400,
VALIDATION: 400,
AUTHENTICATION: 401,
AUTHORIZATION: 403,
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMIT: 429,
INTERNAL: 500,
SERVICE_UNAVAILABLE: 503
};
/** Return a valid 4xx/5xx status from an explicit code, category mapping, or 500 fallback. */
export function resolveStatusCode(statusCode?: number, category?: ErrorCategory): number {
if (
statusCode !== undefined &&
Number.isInteger(statusCode) &&
statusCode >= 400 &&
statusCode <= 599
) {
return statusCode;
}
if (category !== undefined && Object.hasOwn(STATUS_BY_CATEGORY, category)) {
return STATUS_BY_CATEGORY[category] ?? 500;
}
return 500;
}
/** Derive an {@link ErrorCategory} from an HTTP status code. */
export function inferCategory(statusCode: number): ErrorCategory {
if (statusCode === 400 || statusCode === 422) return "VALIDATION";
if (statusCode === 401) return "AUTHENTICATION";
if (statusCode === 403) return "AUTHORIZATION";
if (statusCode === 404) return "NOT_FOUND";
if (statusCode === 409) return "CONFLICT";
if (statusCode === 429) return "RATE_LIMIT";
if (statusCode === 503) return "SERVICE_UNAVAILABLE";
if (statusCode >= 500) return "INTERNAL";
return "BAD_REQUEST";
}
/** Map a status code to a severity level: 5xx = error, 4xx = warning. */
export function inferSeverity(statusCode: number): ErrorSeverity {
return statusCode >= 500 ? "error" : "warning";
}
blocks/error-handler/core/normalize.ts
Converts any thrown value into an Error without exposing it to clients.
/** Wraps a raw thrown value into a canonical Error with the original retained. */
export interface NormalizedInput {
raw: unknown;
error: Error;
}
/** Ensure the input is an Error instance, wrapping non-Error values. */
export function normalizeError(error: unknown): NormalizedInput {
if (error instanceof Error) return { raw: error, error };
return { raw: error, error: new Error("A non-Error value was thrown", { cause: error }) };
}
blocks/error-handler/core/sanitize.ts
Recursively redacts sensitive keys and handles circular or deep values.
/** Built-in case-insensitive key fragments redacted by the sanitizer. */
export const DEFAULT_SENSITIVE_KEYS = [
"password",
"passphrase",
"token",
"authorization",
"secret",
"apikey",
"cookie"
] as const;
const REDACTED = "[REDACTED]";
const CIRCULAR = "[Circular]";
const TRUNCATED = "[Truncated]";
/** Normalizes object keys for case- and punctuation-insensitive sensitivity checks. */
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
}
/** Deep-clone and redact sensitive keys, circular references, and unserializable values. */
export function sanitizeErrorData<T>(value: T, additionalKeys: readonly string[] = []): T {
const sensitiveKeys = [...DEFAULT_SENSITIVE_KEYS, ...additionalKeys].map(normalizeKey);
const seen = new WeakSet<object>();
const visit = (current: unknown, depth: number): unknown => {
if (current === null || current === undefined) return current;
if (typeof current === "bigint") return current.toString();
if (typeof current === "symbol") return current.toString();
if (typeof current === "function") return "[Function]";
if (typeof current !== "object") return current;
if (depth >= 8) return TRUNCATED;
if (seen.has(current)) return CIRCULAR;
seen.add(current);
if (current instanceof Date) {
let result: unknown;
try {
result = current.toISOString();
} catch {
result = "[Unserializable]";
}
seen.delete(current);
return result;
}
if (current instanceof Error) {
const result = visit(
{
name: current.name,
message: current.message,
...(current.stack === undefined ? {} : { stack: current.stack }),
...(current.cause === undefined ? {} : { cause: current.cause })
},
depth + 1
);
seen.delete(current);
return result;
}
if (Array.isArray(current)) {
const result = current.map((entry) => visit(entry, depth + 1));
seen.delete(current);
return result;
}
const sanitized: Record<string, unknown> = {};
let entries: [string, unknown][];
try {
entries = Object.entries(current);
} catch {
seen.delete(current);
return "[Unserializable]";
}
for (const [key, entry] of entries) {
const normalized = normalizeKey(key);
sanitized[key] = sensitiveKeys.some((sensitive) => normalized.includes(sensitive))
? REDACTED
: visit(entry, depth + 1);
}
seen.delete(current);
return sanitized;
};
return visit(value, 0) as T;
}
blocks/error-handler/core/serialize.ts
Builds the default Response Formatter-compatible error envelope.
import type { ErrorSerializer } from "../types/index";
import { INTERNAL_ERROR_MESSAGE } from "./defaults";
/** Default serializer that produces a safe client envelope; hides internal error details. */
export const serializeErrorResponse: ErrorSerializer = (error, context) => {
const message = error.isOperational ? error.message : INTERNAL_ERROR_MESSAGE;
const details = error.isOperational ? error.details : undefined;
return {
success: false,
data: null,
error: { message, ...(details === undefined ? {} : { details }) },
...(context.requestId === undefined ? {} : { requestId: context.requestId })
};
};
blocks/error-handler/adapters/express.ts
Creates Express error middleware and extracts request context.
import type { NextFunction, Request, Response } from "express";
import type { ErrorBoundary, ErrorContextInput } from "../types/index";
interface RequestWithId extends Request {
id?: string;
}
/** Options for the Express error handler adapter. */
export interface ExpressErrorHandlerOptions {
getContext?: (request: Request) => ErrorContextInput;
}
/** Extracts the standard error context available on an Express request. */
function defaultContext(request: RequestWithId): ErrorContextInput {
const header = request.headers["x-request-id"];
const headerId = Array.isArray(header) ? header[0] : header;
return {
method: request.method,
path: request.path,
...(request.id ? { requestId: request.id } : headerId ? { requestId: headerId } : {})
};
}
/** Create an Express error-handler middleware backed by an {@link ErrorBoundary}. */
export function createExpressErrorHandler(
boundary: ErrorBoundary,
options: ExpressErrorHandlerOptions = {}
) {
return async (
error: unknown,
request: Request,
response: Response,
next: NextFunction
): Promise<void> => {
if (response.headersSent) {
next(error);
return;
}
const context = { ...defaultContext(request), ...options.getContext?.(request) };
const result = await boundary.handle(error, context);
response.status(result.statusCode).json(result.body);
};
}
blocks/error-handler/adapters/express.test.ts
Verifies Express status codes, context extraction, and response safety.
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { AppError } from "../core/app-error";
import { createErrorBoundary } from "../core/create-error-boundary";
import { createExpressErrorHandler } from "./express";
describe("createExpressErrorHandler", () => {
it("handles operational errors and extracts request context", async () => {
const report = vi.fn();
const boundary = createErrorBoundary({ reporter: { report } });
const app = express();
app.get("/users/:id", () => {
throw new AppError({ code: "USER_NOT_FOUND", message: "User not found", statusCode: 404 });
});
app.use(
createExpressErrorHandler(boundary, {
getContext: () => ({ userId: "actor-1" })
})
);
const response = await request(app).get("/users/42").set("x-request-id", "req-express");
expect(response.status).toBe(404);
expect(response.body).toEqual({
success: false,
data: null,
error: { message: "User not found" },
requestId: "req-express"
});
expect(report.mock.calls[0]?.[0].context).toMatchObject({
requestId: "req-express",
userId: "actor-1",
method: "GET",
path: "/users/42"
});
});
it("returns a safe response for unknown errors", async () => {
const app = express();
app.get("/crash", () => {
throw new Error("SQL SELECT password FROM users");
});
app.use(createExpressErrorHandler(createErrorBoundary()));
const response = await request(app).get("/crash");
expect(response.status).toBe(500);
expect(response.body.error.message).toBe("Internal server error");
expect(JSON.stringify(response.body)).not.toContain("SELECT");
});
});
blocks/error-handler/adapters/fastify.ts
Registers the boundary through Fastify's setErrorHandler lifecycle.
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { ErrorBoundary, ErrorContextInput } from "../types/index";
/** Options for the Fastify error handler adapter. */
export interface FastifyErrorHandlerOptions {
getContext?: (request: FastifyRequest) => ErrorContextInput;
}
/** Register a Fastify error handler backed by an {@link ErrorBoundary}. */
export function registerFastifyErrorHandler(
app: FastifyInstance,
boundary: ErrorBoundary,
options: FastifyErrorHandlerOptions = {}
): void {
app.setErrorHandler(async (error, request, reply) => {
const customContext = options.getContext?.(request);
const context = {
...customContext,
requestId: request.id,
method: request.method,
path: (request.url ?? "").split("?")[0] ?? ""
};
const result = await boundary.handle(error, context);
return reply.status(result.statusCode).send(result.body);
});
}
blocks/error-handler/adapters/fastify.test.ts
Verifies Fastify error handling with inject.
import Fastify from "fastify";
import { describe, expect, it, vi } from "vitest";
import { AppError } from "../core/app-error";
import { createErrorBoundary } from "../core/create-error-boundary";
import { registerFastifyErrorHandler } from "./fastify";
describe("registerFastifyErrorHandler", () => {
it("uses Fastify's error lifecycle and request context", async () => {
const report = vi.fn();
const app = Fastify({ genReqId: () => "req-fastify" });
registerFastifyErrorHandler(app, createErrorBoundary({ reporter: { report } }), {
getContext: () => ({ userId: "actor-2" })
});
app.get("/private", async () => {
throw new AppError({ code: "FORBIDDEN", message: "Forbidden", category: "AUTHORIZATION" });
});
const response = await app.inject({ method: "GET", url: "/private" });
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({
success: false,
data: null,
error: { message: "Forbidden" },
requestId: "req-fastify"
});
expect(report.mock.calls[0]?.[0].context).toMatchObject({
requestId: "req-fastify",
userId: "actor-2",
method: "GET",
path: "/private"
});
await app.close();
});
it("hides unknown errors", async () => {
const app = Fastify();
registerFastifyErrorHandler(app, createErrorBoundary());
app.get("/crash", async () => {
throw new Error("internal database host");
});
const response = await app.inject({ method: "GET", url: "/crash" });
expect(response.statusCode).toBe(500);
expect(response.json().error.message).toBe("Internal server error");
await app.close();
});
});
blocks/error-handler/adapters/hono.ts
Creates and registers a Hono onError handler.
import type { Context, Env, ErrorHandler, Hono } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { ErrorBoundary, ErrorContextInput } from "../types/index";
/** Options for the Hono error handler adapter. */
export interface HonoErrorHandlerOptions<E extends Env = Env> {
getContext?: (context: Context<E>) => ErrorContextInput;
}
/** Reads a request ID from Hono variables or the incoming request header. */
function requestIdFromContext<E extends Env>(context: Context<E>): string | undefined {
const contextId = (context.var as Record<string, unknown>).requestId;
if (typeof contextId === "string") return contextId;
return context.req.header("x-request-id");
}
/** Create a Hono ErrorHandler function backed by an {@link ErrorBoundary}. */
export function createHonoErrorHandler<E extends Env = Env>(
boundary: ErrorBoundary,
options: HonoErrorHandlerOptions<E> = {}
): ErrorHandler<E> {
return async (error, context) => {
const requestId = requestIdFromContext(context);
const errorContext = {
method: context.req.method,
path: context.req.path,
...(requestId === undefined ? {} : { requestId }),
...options.getContext?.(context)
};
const result = await boundary.handle(error, errorContext);
return context.json(result.body, result.statusCode as ContentfulStatusCode);
};
}
/** Convenience helper that registers the error handler on a Hono instance. */
export function registerHonoErrorHandler<E extends Env>(
app: Hono<E>,
boundary: ErrorBoundary,
options: HonoErrorHandlerOptions<E> = {}
): void {
app.onError(createHonoErrorHandler(boundary, options));
}
blocks/error-handler/adapters/hono.test.ts
Verifies Hono status codes, context, and safe unknown-error responses.
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { AppError } from "../core/app-error";
import { createErrorBoundary } from "../core/create-error-boundary";
import { registerHonoErrorHandler } from "./hono";
describe("createHonoErrorHandler", () => {
it("handles errors and extracts Hono context", async () => {
const report = vi.fn();
const app = new Hono<{ Variables: { requestId: string } }>();
registerHonoErrorHandler(app, createErrorBoundary({ reporter: { report } }), {
getContext: () => ({ userId: "actor-3" })
});
app.get("/users/missing", () => {
throw new AppError({ code: "USER_NOT_FOUND", message: "User not found", statusCode: 404 });
});
const response = await app.request("/users/missing", {
headers: { "x-request-id": "req-hono" }
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({
success: false,
data: null,
error: { message: "User not found" },
requestId: "req-hono"
});
expect(report.mock.calls[0]?.[0].context).toMatchObject({
requestId: "req-hono",
userId: "actor-3",
method: "GET",
path: "/users/missing"
});
});
it("hides unknown errors", async () => {
const app = new Hono();
registerHonoErrorHandler(app, createErrorBoundary());
app.get("/crash", () => {
throw new Error("internal path C:/srv/api.ts");
});
const response = await app.request("/crash");
const body = (await response.json()) as { error: { message: string } };
expect(response.status).toBe(500);
expect(body.error.message).toBe("Internal server error");
});
});
blocks/error-handler/index.ts
Exports the framework-neutral public API.
export { AppError } from "./core/app-error";
export { createErrorBoundary } from "./core/create-error-boundary";
export { DEFAULT_SENSITIVE_KEYS, sanitizeErrorData } from "./core/sanitize";
export { serializeErrorResponse } from "./core/serialize";
export type {
AppErrorOptions,
ClassifiedError,
CreateErrorBoundaryOptions,
ErrorBoundary,
ErrorBoundaryResult,
ErrorCategory,
ErrorClassifier,
ErrorContext,
ErrorContextInput,
ErrorEvent,
ErrorLogger,
ErrorReporter,
ErrorResponse,
ErrorSerializer,
ErrorSeverity
} from "./types/index";
blocks/error-handler/express/app-error.ts
Re-exports AppError from the v2 core for old imports.
/** @deprecated Import AppError from the error-handler root. */
export { AppError } from "../core/app-error";
blocks/error-handler/express/app-error.test.ts
Verifies the deprecated positional constructor through the old path.
import { describe, expect, it } from "vitest";
import { AppError } from "./app-error";
describe("AppError", () => {
it("preserves Error inheritance and operational metadata", () => {
const error = new AppError(409, "User already exists");
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError);
expect(error.name).toBe("AppError");
expect(error.statusCode).toBe(409);
expect(error.message).toBe("User already exists");
expect(error.isOperational).toBe(true);
});
it("allows callers to mark an error as non-operational", () => {
const error = new AppError(500, "Unsafe implementation detail", false);
expect(error.isOperational).toBe(false);
});
});
blocks/error-handler/express/async-handler.ts
Keeps the v1 async Express route wrapper available during migration.
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/express/async-handler.test.ts
Verifies rejected promises still reach Express error middleware.
import { describe, expect, it } from "vitest";
import type { NextFunction, Request, Response } from "express";
import { asyncHandler } from "./async-handler";
function createNextCollector(): { next: NextFunction; calls: unknown[] } {
const calls: unknown[] = [];
const next: NextFunction = ((error?: unknown) => {
calls.push(error);
}) as NextFunction;
return { next, calls };
}
describe("asyncHandler", () => {
it("forwards rejected route promises to next", async () => {
const thrown = new Error("database unavailable");
const { next, calls } = createNextCollector();
const wrapped = asyncHandler(async () => {
throw thrown;
});
wrapped({} as Request, {} as Response, next);
await Promise.resolve();
expect(calls).toEqual([thrown]);
});
it("does not call next when the route resolves successfully", async () => {
const { next, calls } = createNextCollector();
const response = { locals: {} } as Response;
const wrapped = asyncHandler(async (_req: Request, res: Response) => {
res.locals.completed = true;
});
wrapped({} as Request, response, next);
await Promise.resolve();
expect(response.locals.completed).toBe(true);
expect(calls).toEqual([]);
});
});
blocks/error-handler/express/errors.ts
Keeps the v1 error catalog for existing call sites.
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/express/global-error-handler.ts
Delegates the deprecated singleton middleware to a default v2 boundary.
import { createExpressErrorHandler } from "../adapters/express";
import { createErrorBoundary } from "../core/create-error-boundary";
import type { AppErrorOptions } from "../types/index";
function classifyZodError(error: unknown): AppErrorOptions | null {
if (
error !== null &&
typeof error === "object" &&
(error as { name?: string }).name === "ZodError" &&
Array.isArray((error as { issues?: unknown[] }).issues)
) {
return {
code: "VALIDATION_FAILED",
message: "Validation failed",
statusCode: 400,
category: "VALIDATION",
isOperational: true
};
}
return null;
}
/** @deprecated Create a boundary and pass it to createExpressErrorHandler. */
export const globalErrorHandler = createExpressErrorHandler(
createErrorBoundary({
// oxlint-disable-next-line no-console
logger: { error: (ctx, msg) => console.error(msg, ctx) },
classifier: classifyZodError
})
);
blocks/error-handler/express/global-error-handler.test.ts
Verifies the deprecated handler uses the safe v2 response envelope.
import { describe, expect, it } from "vitest";
import express from "express";
import type { Request, Response } from "express";
import request from "supertest";
import { AppError } from "./app-error";
import { globalErrorHandler } from "./global-error-handler";
describe("globalErrorHandler", () => {
it("serializes operational AppError instances without leaking extra fields", async () => {
const app = express();
app.get("/conflict", () => {
throw new AppError(409, "Email already registered");
});
app.use(globalErrorHandler);
const response = await request(app).get("/conflict");
expect(response.status).toBe(409);
expect(response.body).toEqual({
success: false,
data: null,
error: { message: "Email already registered" }
});
});
it("returns a generic 500 response for unknown errors", async () => {
const app = express();
app.get("/crash", (_req: Request, _res: Response) => {
throw new Error("secret connection string leaked here");
});
app.use(globalErrorHandler);
const response = await request(app).get("/crash");
expect(response.status).toBe(500);
expect(response.body).toEqual({
success: false,
data: null,
error: { message: "Internal server error" }
});
expect(JSON.stringify(response.body)).not.toContain("secret connection string");
});
});
blocks/error-handler/express/http-status.ts
Preserves the named v1 HTTP status constants.
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503
} as const;
export type HttpStatus = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];
blocks/error-handler/express/index.ts
Re-exports v2 APIs and deprecated Express helpers from the old entry point.
/** @deprecated Import framework-neutral APIs from the error-handler root. */
export * from "../index.js";
export { createExpressErrorHandler } from "../adapters/express.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";
blocks/error-handler/express/throw-error.ts
Preserves catalog-based AppError throwing for migration.
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/express/throw-error.test.ts
Verifies catalog entries still create operational errors.
import { describe, expect, it } from "vitest";
import { AppError } from "./app-error";
import { ERRORS } from "./errors";
import { throwError } from "./throw-error";
describe("throwError", () => {
it("throws an AppError from a catalog entry", () => {
expect(() => throwError(ERRORS.NOT_FOUND)).toThrow(AppError);
try {
throwError(ERRORS.NOT_FOUND);
} catch (error: unknown) {
expect(error).toBeInstanceOf(AppError);
expect(error).toMatchObject({
statusCode: 404,
message: "Not found",
isOperational: true
});
}
});
});
Configuration
CreateErrorBoundaryOptions
| Option | Type | Default | Description |
|---|---|---|---|
logger | ErrorLogger | None | Receives a sanitized structured event through error() |
reporter | ErrorReporter | None | Receives the sanitized event and may return a promise |
classifier | ErrorClassifier | None | Maps a third-party error to AppErrorOptions |
serializer | ErrorSerializer | serializeErrorResponse | Builds the client response body |
sensitiveKeys | readonly string[] | [] | Adds key fragments to the built-in redaction list |
The built-in sensitive key fragments are password, passphrase, token, authorization, secret, apikey, and cookie. Matching ignores case and punctuation.
AppErrorOptions
| Option | Type | Default | Description |
|---|---|---|---|
code | string | Required | Stable server-side error code |
message | string | Required | Client message when the error is operational |
statusCode | number | Category mapping or 500 | HTTP status from 400 through 599 |
category | ErrorCategory | Inferred from status | Error classification |
severity | ErrorSeverity | warning below 500, otherwise error | Observability severity |
isOperational | boolean | true below 500, otherwise false | Whether the default serializer may expose the message and details |
cause | unknown | None | Original cause for server-side reporting |
metadata | Record<string, unknown> | None | Server-side diagnostic data |
details | unknown | None | Client-safe structured details for operational errors |
Default category mapping
| Category | Status |
|---|---|
BAD_REQUEST, VALIDATION | 400 |
AUTHENTICATION | 401 |
AUTHORIZATION | 403 |
NOT_FOUND | 404 |
CONFLICT | 409 |
RATE_LIMIT | 429 |
INTERNAL | 500 |
SERVICE_UNAVAILABLE | 503 |
Custom categories use 500 unless statusCode supplies a valid error status.
Request context
| Field | Type | Required | Description |
|---|---|---|---|
requestId | string | No | Correlation ID included in the default response |
userId | string | No | Authenticated user identifier for hooks |
path | string | No | Request path |
method | string | No | HTTP method |
timestamp | string | No | Timestamp; defaults to the current ISO timestamp |
metadata | Record<string, unknown> | No | Additional sanitized request data |
Each framework adapter also accepts getContext(requestOrContext). Returned values override the adapter defaults.
Architecture
Framework error
|
v
Adapter extracts method, path, requestId, and custom context
|
v
normalizeError -> classifyError -> enrichErrorContext -> sanitizeErrorData
|
v
logger.error? -> await reporter.report? -> serializer
|
v
{ statusCode, body } -> framework responsecreateErrorBoundary().handle() owns the error rules. Adapters only collect framework context and write the result. A classifier can map Zod or database errors without adding those packages to the core. Logger, reporter, classifier, and serializer failures never replace the original response.
The reporter runs before serialization and is awaited. Give network reporters their own timeout so an unavailable reporting service cannot hold an HTTP response indefinitely.
When to use
- JSON APIs that need one safe error policy across Express, Fastify, or Hono
- Applications that want structured error events without requiring an observability vendor
- Services that need explicit operational and programmer-error behavior
When not to use
- HTML applications where the framework already owns error-page rendering
- Code that needs process-level
uncaughtExceptionor shutdown coordination - Domain code that only needs result types and never crosses an HTTP boundary
Usage
Core
import { AppError, createErrorBoundary } from "@/blocks/error-handler";
const boundary = createErrorBoundary();
const result = await boundary.handle(
new AppError({
code: "USER_NOT_FOUND",
message: "User not found",
category: "NOT_FOUND"
}),
{ requestId: "req-42", userId: "user-7" }
);
// result.statusCode === 404
// result.body.error.message === "User not found"Express
import express from "express";
import { AppError, createErrorBoundary } from "@/blocks/error-handler";
import { createExpressErrorHandler } from "@/blocks/error-handler/adapters/express";
const app = express();
const boundary = createErrorBoundary();
app.get("/users/:id", async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) {
throw new AppError({
code: "USER_NOT_FOUND",
message: "User not found",
category: "NOT_FOUND"
});
}
res.json(user);
});
// Express identifies error handlers by this four-argument middleware.
app.use(createExpressErrorHandler(boundary));Fastify
import Fastify from "fastify";
import { AppError, createErrorBoundary } from "@/blocks/error-handler";
import { registerFastifyErrorHandler } from "@/blocks/error-handler/adapters/fastify";
const app = Fastify();
const boundary = createErrorBoundary();
registerFastifyErrorHandler(app, boundary);
app.get("/account", async () => {
throw new AppError({
code: "AUTH_REQUIRED",
message: "Authentication required",
category: "AUTHENTICATION"
});
});Hono
import { Hono } from "hono";
import { AppError, createErrorBoundary } from "@/blocks/error-handler";
import { registerHonoErrorHandler } from "@/blocks/error-handler/adapters/hono";
const app = new Hono();
const boundary = createErrorBoundary();
// Hono routes thrown errors through app.onError, not upstream middleware.
registerHonoErrorHandler(app, boundary);
app.get("/admin", () => {
throw new AppError({
code: "FORBIDDEN",
message: "Forbidden",
category: "AUTHORIZATION"
});
});Structured logger
The Blockend logger already implements ErrorLogger, so it can be passed directly.
import { logger } from "@/blocks/logger/core";
import { createErrorBoundary } from "@/blocks/error-handler";
const boundary = createErrorBoundary({ logger });Async reporter
import { createErrorBoundary } from "@/blocks/error-handler";
const boundary = createErrorBoundary({
reporter: {
async report(event) {
await errorQueue.publish(event); // The queue client should enforce its own timeout.
}
}
});Zod classifier
Zod is no longer a required dependency. Add it only when the application uses it.
import { z, ZodError } from "zod";
import { createErrorBoundary } from "@/blocks/error-handler";
const boundary = createErrorBoundary({
classifier(error) {
if (!(error instanceof ZodError)) return undefined;
return {
code: "VALIDATION_FAILED",
message: "Validation failed",
category: "VALIDATION",
details: z.treeifyError(error)
};
}
});Response Formatter
The default body already matches ResponseFormatter.error. Supply a serializer only when the application needs to call the formatter itself or change the envelope.
import { ResponseFormatter } from "@/blocks/response-formatter/core";
import { createErrorBoundary } from "@/blocks/error-handler";
const boundary = createErrorBoundary({
serializer(error, context) {
return ResponseFormatter.error(
{
message: error.isOperational ? error.message : "Internal server error",
...(error.isOperational && error.details !== undefined ? { details: error.details } : {})
},
context.requestId
);
}
});API reference
AppError
class AppError extends Error {
constructor(options: AppErrorOptions);
constructor(statusCode: number, message: string, isOperational?: boolean); // deprecated
}Creates a classified application error. The positional overload remains for v1 source compatibility.
createErrorBoundary
function createErrorBoundary(options?: CreateErrorBoundaryOptions): ErrorBoundary;Creates an isolated boundary with optional classifier, logger, reporter, serializer, and redaction configuration.
ErrorBoundary.handle
handle(error: unknown, context?: ErrorContextInput): Promise<ErrorBoundaryResult>;Runs the complete lifecycle. It resolves after the reporter settles and does not reject because an extension hook failed.
sanitizeErrorData
function sanitizeErrorData<T>(value: T, additionalKeys?: readonly string[]): T;Returns a JSON-safe copy, redacts matching keys, marks circular references, and truncates values after eight object levels.
serializeErrorResponse
const serializeErrorResponse: ErrorSerializer;Returns the default formatter-compatible envelope. Non-operational errors receive the message Internal server error.
DEFAULT_SENSITIVE_KEYS
const DEFAULT_SENSITIVE_KEYS: readonly [
"password",
"passphrase",
"token",
"authorization",
"secret",
"apikey",
"cookie"
];Built-in case-insensitive key fragments used by the sanitizer.
createExpressErrorHandler
function createExpressErrorHandler(
boundary: ErrorBoundary,
options?: ExpressErrorHandlerOptions
): ExpressErrorRequestHandler;Returns four-argument Express error middleware. Register it after routes.
registerFastifyErrorHandler
function registerFastifyErrorHandler(
app: FastifyInstance,
boundary: ErrorBoundary,
options?: FastifyErrorHandlerOptions
): void;Registers a handler with app.setErrorHandler.
createHonoErrorHandler
function createHonoErrorHandler(
boundary: ErrorBoundary,
options?: HonoErrorHandlerOptions
): ErrorHandler;Creates a Hono error handler for app.onError.
registerHonoErrorHandler
function registerHonoErrorHandler(
app: Hono,
boundary: ErrorBoundary,
options?: HonoErrorHandlerOptions
): void;Registers the created handler with app.onError.
AppErrorOptions
The constructor options documented in Configuration.
CreateErrorBoundaryOptions
The boundary options documented in Configuration.
ErrorCategory
type ErrorCategory =
| "BAD_REQUEST"
| "VALIDATION"
| "AUTHENTICATION"
| "AUTHORIZATION"
| "NOT_FOUND"
| "CONFLICT"
| "RATE_LIMIT"
| "INTERNAL"
| "SERVICE_UNAVAILABLE"
| (string & {});ErrorSeverity
type ErrorSeverity = "info" | "warning" | "error" | "critical";ErrorContextInput and ErrorContext
interface ErrorContextInput {
requestId?: string;
userId?: string;
path?: string;
method?: string;
timestamp?: string;
metadata?: Record<string, unknown>;
}
interface ErrorContext extends ErrorContextInput {
timestamp: string;
}ClassifiedError
interface ClassifiedError {
name: string;
code: string;
message: string;
statusCode: number;
category: ErrorCategory;
severity: ErrorSeverity;
isOperational: boolean;
stack?: string;
cause?: unknown;
metadata?: Record<string, unknown>;
details?: unknown;
}ErrorEvent
Contains a sanitized classified error and an enriched ErrorContext. Logger and reporter hooks receive this shape.
ErrorResponse
interface ErrorResponse {
success: false;
data: null;
error: { message: string; details?: unknown };
requestId?: string;
}ErrorBoundaryResult
interface ErrorBoundaryResult {
statusCode: number;
body: ErrorResponse;
}ErrorLogger
interface ErrorLogger {
error(context: Record<string, unknown>, message?: string): void;
}ErrorReporter
interface ErrorReporter {
report(event: ErrorEvent): void | Promise<void>;
}ErrorClassifier
type ErrorClassifier = (error: unknown) => AppErrorOptions | null | undefined;ErrorSerializer
type ErrorSerializer = (error: ClassifiedError, context: ErrorContext) => ErrorResponse;Adapter options
ExpressErrorHandlerOptions, FastifyErrorHandlerOptions, and HonoErrorHandlerOptions each contain one optional getContext function. The function receives the native request or Hono context and returns ErrorContextInput.
Examples
Production configuration
import { z, ZodError } from "zod";
import { logger } from "@/blocks/logger/core";
import { createErrorBoundary } from "@/blocks/error-handler";
export const errorBoundary = createErrorBoundary({
logger,
sensitiveKeys: ["privateKey"],
classifier(error) {
if (!(error instanceof ZodError)) return undefined;
return {
code: "VALIDATION_FAILED",
message: "Validation failed",
category: "VALIDATION",
details: z.treeifyError(error)
};
},
reporter: {
async report(event) {
await errorQueue.publish(event);
}
}
});Use adapter getContext to add authenticated user IDs. Do not put raw request bodies or full headers in context. Pass only the fields an operator needs.
Testing application errors
import { expect, it } from "vitest";
import { AppError, createErrorBoundary } from "@/blocks/error-handler";
it("returns a safe not-found response", async () => {
const boundary = createErrorBoundary();
const result = await boundary.handle(
new AppError({
code: "INVOICE_NOT_FOUND",
message: "Invoice not found",
category: "NOT_FOUND"
})
);
expect(result.statusCode).toBe(404);
expect(result.body.error.message).toBe("Invoice not found");
});Migration from v1
The registry key and output folder remain error-handler. Update imports and initialization in place.
// v1
import { AppError, globalErrorHandler } from "@/blocks/error-handler/express";
throw new AppError(404, "User not found");
app.use(globalErrorHandler);
// v2
import { AppError, createErrorBoundary } from "@/blocks/error-handler";
import { createExpressErrorHandler } from "@/blocks/error-handler/adapters/express";
throw new AppError({
code: "USER_NOT_FOUND",
message: "User not found",
category: "NOT_FOUND"
});
app.use(createExpressErrorHandler(createErrorBoundary()));The default response changed from a top-level message to error.message. Automatic Zod
detection was removed. Add the classifier shown above when the application needs Zod details.
AppError(status, message, isOperational?), globalErrorHandler, throwError, ERRORS, HTTP_STATUS, and asyncHandler remain under error-handler/express for migration. New code should use the root core and an adapter.
Production checklist
- Add a logger or reporter if errors must be retained. With neither configured, the boundary only returns a response.
- Put timeouts in remote reporters because the boundary awaits
report(). - Mark a 5xx
AppErroroperational only when its message and details are safe for clients. - Keep secrets under descriptive keys so the sanitizer can redact them. It does not inspect arbitrary prose for embedded credentials.
- Handle process crashes with Graceful Shutdown. This block handles request failures, not process lifecycle.
Related blocks
- Logger provides a structured
error(context, message)method accepted directly bycreateErrorBoundary. - Response Formatter uses the same default error envelope and can be supplied through
serializer.
FAQ
Does the core require a framework or logger?
No. createErrorBoundary() has no required option or runtime dependency.
Are unknown error messages or stack traces returned to clients?
No. Unknown and non-operational errors return status 500 with Internal server error. Stack, cause, metadata, and the original message remain server-side.
Why does Hono use onError instead of middleware?
Hono routes thrown handler errors through its error lifecycle before upstream middleware can replace the response. registerHonoErrorHandler installs the boundary at that supported lifecycle point.
What happens if logging, reporting, classification, or serialization fails?
The boundary catches the extension failure. It continues with the original error and falls back to the default serializer when needed.
Can an operational 500 expose its message?
Yes, but only when isOperational: true is explicit. Errors at 500 or above default to non-operational.
Does redaction find a token embedded in a free-form string?
No. Redaction matches object keys. Unknown error strings never enter the default client response, but integrations should still avoid attaching secrets to exception messages.
Changelog
2.0.0 - 2026-09-16
- Rebuilt the Express-only handler as a framework-neutral error boundary.
- Added native Express, Fastify, and Hono adapters.
- Added error codes, categories, severity, causes, metadata, request context, redaction, and optional logger or reporter hooks.
- Changed the default response to the Response Formatter-compatible envelope.
- Removed the required Zod dependency and replaced automatic Zod handling with a classifier example.
- Kept the
error-handlerregistry identity and deprecated v1 Express exports for migration.
1.0.0
- Initial Express global error handler with
AppError, a shared catalog, and Zod handling.