https://github.com/paveg/hono-problem-details
RFC 9457 Problem Details middleware for Hono
https://github.com/paveg/hono-problem-details
cloudflare-workers error-handling hono middleware problem-details rfc-9457 typescript
Last synced: 2 months ago
JSON representation
RFC 9457 Problem Details middleware for Hono
- Host: GitHub
- URL: https://github.com/paveg/hono-problem-details
- Owner: paveg
- License: mit
- Created: 2026-02-24T16:49:09.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-04-11T05:57:50.000Z (2 months ago)
- Last Synced: 2026-04-11T06:17:16.473Z (2 months ago)
- Topics: cloudflare-workers, error-handling, hono, middleware, problem-details, rfc-9457, typescript
- Language: TypeScript
- Size: 239 KB
- Stars: 3
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# hono-problem-details
[](https://www.npmjs.com/package/hono-problem-details)
[](https://www.npmjs.com/package/hono-problem-details)
[](https://bundlephobia.com/package/hono-problem-details)
[](https://github.com/paveg/hono-problem-details/stargazers)
[](https://github.com/paveg/hono-problem-details/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
[](https://coderabbit.ai)
[](https://app.devin.ai/org/ryota-ikezawa/wiki/paveg/hono-problem-details)
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) Problem Details middleware for [Hono](https://hono.dev).
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](https://github.com/paveg/hono-problem-details) — 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 `retryAfter` or `correlationId` means breaking your own contract
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) 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.onError` handler with RFC-compliant defaults
- **Zod integration** — `@hono/zod-validator` hook for validation errors
- **Valibot integration** — `@hono/valibot-validator` hook for validation errors
- **OpenAPI integration** — `@hono/zod-openapi` schemas for API documentation
- **Standard Schema** — `@hono/standard-validator` hook (works with any schema library)
- **Type-safe** — full TypeScript support with inference
- **Zero runtime dependencies** — `hono` is the only required peer dependency; validator integrations are optional
- **Localization** — `localize` callback for title/detail translation
- **Edge-first** — works on Cloudflare Workers, Deno, Bun, and Node.js
## Install
```bash
npm install hono-problem-details
```
## Quick Start
```ts
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](#zod-validator-integration) / [Valibot](#valibot-validator-integration) / [Standard Schema](#standard-schema-integration)
hooks — this section is for errors you throw yourself.
```ts
import { problemDetails } from "hono-problem-details";
```
### Unauthorized — 401
```ts
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
```ts
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
```ts
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.** `title` is optional — when omitted, the standard HTTP reason phrase
> for `status` is used (`404` → `"Not Found"`). Similarly, `instance` can be populated from
> the request path automatically via `problemDetailsHandler({ autoInstance: true })`. Both
> shortcuts skip the boilerplate in the example above; explicit values always win.
### Conflict — 409
```ts
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
```ts
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.
## Extension Members
Extension members are flattened to top level per RFC 9457:
```ts
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:
```ts
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
```ts
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
```ts
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](https://standardschema.dev/) compatible library (Zod, Valibot, ArkType, etc.):
```ts
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
Use with `@hono/zod-openapi` to document Problem Details error responses in your OpenAPI spec:
```ts
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { problemDetailsHandler } from "hono-problem-details";
import {
ProblemDetailsSchema,
createProblemDetailsSchema,
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"),
},
});
// 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)
```
## 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.
```ts
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`, add `Vary: Accept-Language`
> from your own middleware so CDNs and browser caches don't serve the wrong translation.
> This middleware intentionally does not set `Vary` — error handlers shouldn't mutate
> request-scope headers that also apply to successful responses.
> **Note on failures**: If your `localize` callback throws, the handler falls back to the
> un-localized `ProblemDetails` and continues. Throwing from inside `app.onError` would cause
> the error handler to re-enter itself, so the swallow is deliberate. Catch errors inside your
> callback if you need to observe them.
## Handler Options
```ts
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](https://github.com/paveg/hono-idempotency) — Idempotency key middleware for Hono
- [hono-webhook-verify](https://github.com/paveg/hono-webhook-verify) — Webhook signature verification middleware for Hono
- [hono-cf-access](https://github.com/paveg/hono-cf-access) — Country / ASN blocking and maintenance mode via Cloudflare Workers `request.cf`
## License
MIT