hono-problem-details
v0.8.1
Published
RFC 9457 Problem Details middleware for Hono
Maintainers
Readme
hono-problem-details
RFC 9457 Problem Details middleware for Hono.
Returns application/problem+json structured error responses with a single app.onError setup.
If this saved you from hand-rolling RFC 9457 in yet another Hono project, please ⭐ star the repo — it helps others discover it.
Why hono-problem-details?
Without a contract, HTTP error bodies drift. Every Hono project ends up reinventing the same scaffolding — and every client ends up parsing whatever shows up.
- Inconsistent shapes across routes:
{ message },{ error },{ code, reason }, or raw text - Validation errors from each schema library return a different format, so clients special-case each
- OpenAPI drift: docs describe one error shape, the server returns another
- No standard for extensions: adding
retryAfterorcorrelationIdmeans breaking your own contract
RFC 9457 defines one structure — { type, status, title,
detail, instance } plus arbitrary extension members — and this middleware makes it the default for every
error in your Hono app: thrown ProblemDetailsError, HTTPException, validation failures, and unhandled
exceptions alike. One app.onError() line, one contract your clients, OpenAPI spec, and integration tests
can all agree on.
Features
- RFC 9457 compliant —
type,status,title,detail,instance+ extension members (flattened per §3.1, standard fields always win) - Hono native —
app.onErrorhandler with RFC-compliant defaults - Zod integration —
@hono/zod-validatorhook for validation errors - Valibot integration —
@hono/valibot-validatorhook for validation errors - OpenAPI integration —
@hono/zod-openapischemas for API documentation - OpenTelemetry integration —
@opentelemetry/apifor automatically injecting trace information. - Standard Schema —
@hono/standard-validatorhook (works with any schema library) - Type-safe — full TypeScript support with inference
- Zero runtime dependencies —
honois the only required peer dependency; validation and opentelemetry integrations are optional - Localization —
localizecallback for title/detail translation - Edge-first — works on Cloudflare Workers, Deno, Bun, and Node.js
Install
npm install hono-problem-detailsRequirements
- Hono
>= 4.12.14(peer dependency) - TypeScript
>= 5.0— the published.d.tsfiles are CI-tested against TS 5.0, 5.4, 5.7, 5.9, and 6.0. Older TS versions may work but are not verified. - Node.js
>= 22(Node 20 reached end-of-life in April 2026; v0.6.0 raised the floor)
Quick Start
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { problemDetailsHandler } from "hono-problem-details";
const app = new Hono();
app.onError(problemDetailsHandler());
app.get("/not-found", (c) => {
throw new HTTPException(404, { message: "Resource not found" });
});
// Response:
// HTTP/1.1 404 Not Found
// Content-Type: application/problem+json
// {
// "type": "about:blank",
// "status": 404,
// "title": "Not Found",
// "detail": "Resource not found"
// }Patterns
Common error shapes for day-to-day API work. Validation errors are covered separately by the Zod / Valibot / Standard Schema hooks — this section is for errors you throw yourself.
import { problemDetails } from "hono-problem-details";Unauthorized — 401
throw problemDetails({
status: 401,
title: "Unauthorized",
detail: "Missing or invalid credentials",
type: "https://api.example.com/problems/unauthorized",
});Clients key off type to trigger a re-auth flow — no need to parse detail.
Forbidden — 403
throw problemDetails({
status: 403,
title: "Forbidden",
detail: `User ${userId} cannot access resource ${resourceId}`,
type: "https://api.example.com/problems/forbidden",
extensions: { requiredRole: "admin" },
});Not Found — 404
throw problemDetails({
status: 404,
title: "Not Found",
detail: `Order ${orderId} does not exist`,
instance: `/orders/${orderId}`,
});instance points at the specific occurrence — clients can use it as a key for retry logic
or deduplication.
Auto-fill shortcuts.
titleis optional — when omitted, the standard HTTP reason phrase forstatusis used (404→"Not Found"). Similarly,instancecan be populated from the request path automatically viaproblemDetailsHandler({ autoInstance: true }). Both shortcuts skip the boilerplate in the example above; explicit values always win.
Conflict — 409
throw problemDetails({
status: 409,
title: "Order Conflict",
detail: `Order ${orderId} already exists`,
type: "https://api.example.com/problems/order-conflict",
instance: `/orders/${orderId}`,
});Domain conflicts should always carry a project-specific type URI. about:blank is fine for
generic 4xx/5xx but loses its value the moment a client needs to distinguish two conflicts.
Too Many Requests — 429
throw problemDetails({
status: 429,
title: "Too Many Requests",
detail: "Request quota exceeded",
type: "https://api.example.com/problems/rate-limited",
extensions: { retryAfter: 60, quota: 1000, remaining: 0 },
});Rate-limit metadata goes in extensions — clients read it straight from the body instead
of juggling Retry-After headers.
Unhandled Errors — 500
Anything thrown that isn't a ProblemDetailsError or HTTPException (and isn't matched by
mapError) becomes a generic 500. detail is always the constant string
"An unexpected error occurred" — never the raw error.message or stack — so UIs that
render detail verbatim cannot leak server internals.
app.get("/boom", () => {
throw new Error("DB connection lost: ECONNREFUSED");
});
// HTTP/1.1 500 Internal Server Error
// Content-Type: application/problem+json
// {
// "type": "about:blank",
// "status": 500,
// "title": "Internal Server Error",
// "detail": "An unexpected error occurred"
// }In development, set includeStack: true to surface the stack trace as a top-level stack
extension member. detail stays constant either way — read the stack from body.stack:
problemDetailsHandler({
includeStack: process.env.NODE_ENV !== "production",
});
// HTTP/1.1 500 Internal Server Error
// {
// "type": "about:blank",
// "status": 500,
// "title": "Internal Server Error",
// "detail": "An unexpected error occurred",
// "stack": "Error: DB connection lost: ECONNREFUSED\n at ..."
// }Keep includeStack off in production — stack traces should not leave the server even via
opt-in extension fields.
Extension Members
Extension members are flattened to top level per RFC 9457:
throw problemDetails({
status: 422,
title: "Validation Error",
extensions: {
errors: [
{ field: "email", message: "must be a valid email" },
],
},
});
// Response body:
// {
// "type": "about:blank",
// "status": 422,
// "title": "Validation Error",
// "errors": [{ "field": "email", "message": "must be a valid email" }]
// }Problem Type Registry
Pre-define your API's error types for type-safe error creation:
import { createProblemTypeRegistry } from "hono-problem-details";
const problems = createProblemTypeRegistry({
ORDER_CONFLICT: {
type: "https://api.example.com/problems/order-conflict",
status: 409,
title: "Order Conflict",
},
RATE_LIMITED: {
type: "https://api.example.com/problems/rate-limited",
status: 429,
title: "Too Many Requests",
},
});
// Type-safe error creation
app.post("/orders", (c) => {
throw problems.create("ORDER_CONFLICT", {
detail: `Order ${id} already exists`,
instance: `/orders/${id}`,
});
});
// With extensions
throw problems.create("RATE_LIMITED", {
extensions: { retryAfter: 60 },
});When to use the registry vs problemDetails()
Reach for createProblemTypeRegistry when your API has a fixed set of domain errors and you
want one source of truth for type / status / title. It pays off the moment the same error
is thrown from more than one handler — renames and URI changes happen in one place.
Use problemDetails() directly for one-off errors, prototypes, or generic 4xx/5xx where
about:blank is the right type. RFC 9457 explicitly allows about:blank when the HTTP status
code alone is enough context — don't force a URI just to have one.
Zod Validator Integration
import { zValidator } from "@hono/zod-validator";
import { zodProblemHook } from "hono-problem-details/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
age: z.number().positive(),
});
app.post("/users", zValidator("json", schema, zodProblemHook()), (c) => {
const data = c.req.valid("json");
// ...
});
// Validation error response:
// HTTP/1.1 422 Unprocessable Content
// Content-Type: application/problem+json
// {
// "type": "about:blank",
// "status": 422,
// "title": "Validation Error",
// "detail": "Request validation failed",
// "errors": [{ "field": "email", "message": "Invalid email", "code": "invalid_string" }]
// }Valibot Validator Integration
import { vValidator } from "@hono/valibot-validator";
import { valibotProblemHook } from "hono-problem-details/valibot";
import * as v from "valibot";
const schema = v.object({
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.minValue(1)),
});
app.post("/users", vValidator("json", schema, valibotProblemHook()), (c) => {
const data = c.req.valid("json");
// ...
});Standard Schema Integration
Works with any Standard Schema compatible library (Zod, Valibot, ArkType, etc.):
import { sValidator } from "@hono/standard-validator";
import { standardSchemaProblemHook } from "hono-problem-details/standard-schema";
import { z } from "zod"; // or valibot, arktype, etc.
const schema = z.object({
email: z.string().email(),
});
app.post("/users", sValidator("json", schema, standardSchemaProblemHook()), (c) => {
const data = c.req.valid("json");
// ...
});OpenAPI Integration
Peer dependencies:
./openapirequires@hono/zod-openapi@^1.0.0, which in turn requireszod@^4.0.0. The base./zodintegration (validator hook) works with bothzod@^3.25.0andzod@^4.0.0— the version constraint above only applies when you import fromhono-problem-details/openapi.
Use with @hono/zod-openapi to document Problem Details error responses in your OpenAPI spec:
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { problemDetailsHandler } from "hono-problem-details";
import {
createProblemDetailsSchema,
getProblemDetailsSchema,
problemDetailsResponse,
} from "hono-problem-details/openapi";
const app = new OpenAPIHono();
app.onError(problemDetailsHandler());
// Use problemDetailsResponse() in route definitions
const route = createRoute({
method: "get",
path: "/users/{id}",
request: {
params: z.object({ id: z.string() }),
},
responses: {
200: {
content: {
"application/json": {
schema: z.object({ id: z.string(), name: z.string() }),
},
},
description: "User found",
},
404: problemDetailsResponse(404),
422: problemDetailsResponse(422, "Validation Error"),
},
});
// Need the schema itself? Call the factory:
const ProblemDetailsSchema = getProblemDetailsSchema();
// With extension members
const errorWithExtensions = createProblemDetailsSchema(
z.object({
errors: z.array(z.object({ field: z.string(), message: z.string() })),
}),
);
// Use: problemDetailsResponse(422, "Validation Error", errorWithExtensions)Migrating from v0.6.x:
ProblemDetailsSchema(the const export) was removed in v0.7.0. Replaceimport { ProblemDetailsSchema }withimport { getProblemDetailsSchema }and call it where you previously read the const. The factory is memoized — repeat calls return the same instance. See ADR-0004 for the bundler-related reason this changed.
Localization
Use the localize callback to translate title and detail based on the request context.
Return a partial patch with just the fields you want to override — everything else falls
through unchanged. Returning nothing (or undefined) leaves the response untouched.
problemDetailsHandler({
localize: (pd, c) => {
const lang = c.req.header("Accept-Language");
if (lang?.startsWith("ja")) {
return { title: translate("ja", pd.title) };
}
return pd;
},
});The callback receives the fully-built ProblemDetails object and the Hono Context, allowing access to headers like Accept-Language. Return a new ProblemDetails with translated fields.
Note on caching: If your responses vary by
Accept-Language, addVary: Accept-Languagefrom your own middleware so CDNs and browser caches don't serve the wrong translation. This middleware intentionally does not setVary— error handlers shouldn't mutate request-scope headers that also apply to successful responses.
Note on failures: If your
localizecallback throws, the handler falls back to the un-localizedProblemDetailsand continues. Throwing from insideapp.onErrorwould cause the error handler to re-enter itself, so the swallow is deliberate. Catch errors inside your callback if you need to observe them.
OpenTelemetry Integration
To enable OpenTelemetry instrumentation in problem details responses, pass the OpenTelemetry API as an option to the handler:
import * as otelApi from "@opentelemetry/api";
problemDetailsHandler({
otelApi,
});When enabled, the handler adds a traceId extension member to all problem details responses when a trace is active.
This allows clients to correlate errors with server-side traces for easier debugging.
Handler Options
problemDetailsHandler({
// Prefix for type URI (e.g., "https://api.example.com/problems")
typePrefix: "https://api.example.com/problems",
// Default type URI (default: "about:blank")
defaultType: "about:blank",
// Include stack trace in `extensions.stack` on 500 responses (development only)
includeStack: process.env.NODE_ENV === "development",
// Populate `instance` from `c.req.path` when the thrown problem didn't specify one
autoInstance: true,
// Localize title/detail before sending the response.
// Return a partial patch — fields you omit fall through from the original.
localize: (pd, c) => {
const lang = c.req.header("Accept-Language") ?? "en";
return { title: `[${lang}] ${pd.title}` };
},
// Custom error mapping
mapError: (error) => {
if (error instanceof MyCustomError) {
return {
status: error.statusCode,
title: error.name,
detail: error.message,
};
}
return undefined; // fallback to default handling
},
});Used By
The following Hono middleware libraries use hono-problem-details as an optional dependency for RFC 9457 error responses:
- hono-idempotency — Idempotency key middleware for Hono
- hono-webhook-verify — Webhook signature verification middleware for Hono
- hono-cf-access — Country / ASN blocking and maintenance mode via Cloudflare Workers
request.cf
License
MIT
