http-errors with Express error middleware: working versions (tested 2026-10-18)
Express routes that throw http-errors and reject asynchronously, mapped by a central middleware to RFC 7807 problem details with expose-aware messages and a 500 fallback, tested offline with supertest.
passed7 passed · 0 failed2026-10-18 03:58 UTC- Status
- passed
- Last passed
- 2026-10-18 03:58 UTC
- Tests
- 7 / 7
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 39095442401
The code
Exactly the files that passed. Your agent can pull them with get_recipe("http-errors-express-error-middleware") (install), or see them on GitHub.
{
"name": "http-errors-express-error-middleware",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"express": "5.3.0",
"http-errors": "2.0.1",
"supertest": "7.3.1"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/http-errors": "2.0.5",
"@types/node": "26.6.5",
"@types/supertest": "7.2.1",
"tsx": "4.23.15"
}
}
import { STATUS_CODES } from "node:http";
import { isHttpError } from "http-errors";
import type { ErrorRequestHandler, Request } from "express";
export type ProblemDetails = {
type: string;
title: string;
status: number;
detail: string;
instance: string;
[key: string]: unknown;
};
const RESERVED = new Set([
"type",
"title",
"status",
"detail",
"instance",
"statusCode",
"expose",
"name",
"message",
"stack",
"headers",
]);
export function toProblemDetails(err: unknown, req: Request): ProblemDetails {
const httpError = isHttpError(err) ? err : undefined;
const status = httpError ? httpError.status : 500;
const title = STATUS_CODES[status] ?? "Internal Server Error";
const expose = httpError ? httpError.expose : false;
const detail = expose && httpError ? httpError.message : title;
const problem: ProblemDetails = {
type: `https://example.com/problems/${status}`,
title,
status,
detail,
instance: req.originalUrl,
};
if (expose) {
for (const [key, value] of Object.entries(err as Record<string, unknown>)) {
if (RESERVED.has(key)) continue;
problem[key] = value;
}
}
return problem;
}
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) {
next(err);
return;
}
const problem = toProblemDetails(err, req);
res.status(problem.status).type("application/problem+json").json(problem);
};
import createError from "http-errors";
import express from "express";
import { errorHandler } from "./problem.ts";
type User = { id: string; email: string };
export function createApp() {
const app = express();
app.use(express.json());
const users = new Map<string, User>([
["1", { id: "1", email: "[email protected]" }],
]);
app.get("/users/:id", (req, res) => {
const user = users.get(req.params.id);
if (!user) {
throw createError(404, "User not found");
}
res.json(user);
});
app.post("/users", (req, res) => {
const email: unknown = (req.body ?? {}).email;
if (typeof email !== "string" || !email.includes("@")) {
throw createError(422, "Validation failed", {
errors: [{ field: "email", message: "must be a valid email" }],
});
}
const id = String(users.size + 1);
const user: User = { id, email };
users.set(id, user);
res.status(201).json(user);
});
app.get("/reports/:id", async (req, res) => {
await Promise.resolve();
if (req.params.id !== "42") {
throw createError(404, `Report ${req.params.id} not found`);
}
res.json({ id: req.params.id });
});
app.get("/maintenance", () => {
throw createError(503, "Down for maintenance", { expose: true });
});
app.get("/boom", () => {
throw new Error("secret database credentials");
});
app.use((_req, _res, next) => {
next(createError(404, "Route not found"));
});
app.use(errorHandler);
return app;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { createApp } from "../src/app.ts";
const app = createApp();
test("returns a known user", async () => {
const res = await request(app).get("/users/1");
assert.equal(res.status, 200);
assert.deepEqual(res.body, { id: "1", email: "[email protected]" });
});
test("maps a thrown 404 to problem details", async () => {
const res = await request(app).get("/users/999");
assert.equal(res.status, 404);
assert.match(String(res.headers["content-type"]), /application\/problem\+json/);
assert.equal(res.body.type, "https://example.com/problems/404");
assert.equal(res.body.title, "Not Found");
assert.equal(res.body.status, 404);
assert.equal(res.body.detail, "User not found");
assert.equal(res.body.instance, "/users/999");
});
test("exposes custom properties on a 422", async () => {
const res = await request(app).post("/users").send({ email: "not-an-email" });
assert.equal(res.status, 422);
assert.equal(res.body.title, "Unprocessable Entity");
assert.equal(res.body.detail, "Validation failed");
assert.deepEqual(res.body.errors, [
{ field: "email", message: "must be a valid email" },
]);
});
test("forwards async route rejections", async () => {
const res = await request(app).get("/reports/7");
assert.equal(res.status, 404);
assert.equal(res.body.detail, "Report 7 not found");
});
test("serves an exposed 5xx message when opted in", async () => {
const res = await request(app).get("/maintenance");
assert.equal(res.status, 503);
assert.equal(res.body.detail, "Down for maintenance");
});
test("hides the message of an unexposed 500", async () => {
const res = await request(app).get("/boom");
assert.equal(res.status, 500);
assert.equal(res.body.title, "Internal Server Error");
assert.equal(res.body.detail, "Internal Server Error");
assert.equal(JSON.stringify(res.body).includes("secret"), false);
});
test("returns problem details for unknown routes", async () => {
const res = await request(app).get("/missing");
assert.equal(res.status, 404);
assert.equal(res.body.detail, "Route not found");
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
cors express preflight
Configure cors with express to reflect allowed origins, answer OPTIONS preflight with Access-Control-Allow-* headers, and deny a disallowed origin, tested offline with supertest.
express 5 async error handling
Use express 5 with supertest to forward rejected async handlers to a JSON error middleware, with router-level params, a JSON 404 handler and offline tests.
express-openapi-validator request and response validation
Validate an Express app against an inline OpenAPI 3 spec using express-openapi-validator, returning validation errors as JSON and testing valid and invalid requests offline with supertest.
express-validator request checks with supertest
Validate Express JSON bodies and query params with express-validator, returning field errors, tested with supertest offline.
All Express recipesAll http-errors recipesAll supertest recipes