Validation & errors
Reject bad input precisely, and fail in one predictable shape.
What it is
Every request is checked against a Zod schema before the handler runs: types, lengths, allowed fields. When something fails, the API answers with Problem Details (RFC 9457): JSON with a type, title, status and detail, sent as application/problem+json. Validation failures add an errors list with the path of each bad field, so a form can put each message next to its input. One shape for every error means a client needs one error handler, not one per endpoint.
When to use it
- Any API that accepts input from someone else
- Forms that should show errors next to the right field
- Clients in several languages that need to handle errors the same way
Break it on purpose
422: both problems at once, each with its path.
Press Send to see the response.
400 or 422? 400 Bad Request when the request can't be read (malformed JSON, wrong content type), 422 Unprocessable Content when it can be read but isn't valid. Many APIs use 400 for both; keeping them apart tells the client whether the bug is in how it encodes requests or in the data itself.
Show code · src/api/problem.ts
import { z } from "@hono/zod-openapi";
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
// Errors as RFC 9457 "Problem Details": one predictable JSON shape for every
// failure, with its own media type, so clients can handle errors generically.
export const Problem = z
.object({
type: z.string().openapi({ example: "https://api.djmiskey.com/problems/validation" }),
title: z.string().openapi({ example: "Your request isn't valid" }),
status: z.number().int().openapi({ example: 422 }),
detail: z.string().optional(),
instance: z.string().optional().openapi({ example: "/api/tasks" }),
errors: z.array(z.object({ path: z.string(), message: z.string() })).optional(),
})
.openapi("Problem");
export type Problem = z.infer<typeof Problem>;
const TITLES: Record<string, string> = {
"bad-request": "The request couldn't be read",
validation: "Your request isn't valid",
"not-found": "Not found",
"method-not-allowed": "Method not allowed",
"precondition-failed": "The resource changed since you last read it",
"unsupported-version": "That API version doesn't exist",
unauthorized: "Authentication required",
forbidden: "Not allowed with these credentials",
"rate-limited": "Too many requests",
unavailable: "Not available right now",
internal: "Something went wrong on our side",
};
export function problem<S extends ContentfulStatusCode>(
c: Context,
status: S,
kind: keyof typeof TITLES,
extra: Pick<Problem, "detail" | "errors"> = {},
) {
const body: Problem = {
type: `https://api.djmiskey.com/problems/${kind}`,
title: TITLES[kind],
status,
instance: new URL(c.req.url).pathname,
...extra,
};
// c.json keeps the response typed for the OpenAPI routes; the explicit
// content-type replaces its default application/json.
return c.json(body, status, { "content-type": "application/problem+json" });
}
Show code · src/api/validation.ts
import type { Hook } from "@hono/zod-openapi";
import { problem } from "./problem";
// Called by every route when its params, query or body fail their Zod schema:
// answer 422 with one entry per problem, pointing at the field ("title",
// "query.done") so a form can show the message next to the right input.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const validationHook: Hook<any, any, any, any> = (result, c) => {
if (result.success) return;
const errors = result.error.issues.map((i) => ({
path: [result.target === "json" ? null : result.target, ...i.path].filter((p) => p !== null).join(".") || result.target,
message: i.message,
}));
return problem(c, 422, "validation", { detail: `${errors.length} field${errors.length === 1 ? "" : "s"} need${errors.length === 1 ? "s" : ""} fixing.`, errors });
};
Show code · src/api/app.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { HTTPException } from "hono/http-exception";
import { type AuthEnv, authenticate } from "./auth";
import { problem } from "./problem";
import { rateLimit } from "./ratelimit";
import { authApi } from "./routes/auth";
import { limitsApi } from "./routes/limits";
import { tasksApi } from "./routes/tasks";
import { validationHook } from "./validation";
// The whole API: one Hono app under /api, mounted into Next.js by
// src/app/api/[[...route]]/route.ts. Hono only sees standard Request and
// Response objects, so the same app could run on Workers, Deno or Bun.
export const app = new OpenAPIHono<AuthEnv>({ defaultHook: validationHook }).basePath("/api");
// Every request: check credentials if any were sent (see auth.ts).
app.use("*", authenticate);
// Writes to the public tasks are rate limited per client: 30 a minute
// anonymously, 120 with an API key or token. (LAB_WRITE_LIMIT raises the
// anonymous limit for the test suites.)
// ("/tasks/*" also matches "/tasks" itself: listing both would count every
// write twice.)
app.on(["POST", "PATCH", "DELETE"], "/tasks/*", rateLimit({
name: "writes",
windowSec: 60,
limit: (c) => (c.get("auth") ? 120 : Number(process.env.LAB_WRITE_LIMIT ?? 30)),
}));
app.get("/health", (c) => c.json({ ok: true }));
app.route("/", tasksApi);
app.route("/", authApi);
app.route("/", limitsApi);
// The OpenAPI document is generated from the same Zod schemas that validate
// requests, so the docs can't drift from the code.
app.doc31("/openapi.json", {
openapi: "3.1.0",
info: {
title: "API Feature Lab",
version: "1.0.0",
description: "The API behind https://api.djmiskey.com: one page per technique, each calling these endpoints live.",
},
servers: [{ url: "https://api.djmiskey.com" }],
});
// Every failure leaves as Problem Details, even ones Hono raises itself.
app.notFound((c) => problem(c, 404, "not-found", { detail: `No route for ${c.req.method} ${new URL(c.req.url).pathname}.` }));
app.onError((err, c) => {
if (err instanceof HTTPException) {
// Hono's JSON validator throws 400 for a body that isn't valid JSON.
return problem(c, err.status === 400 ? 400 : 500, err.status === 400 ? "bad-request" : "internal", { detail: err.message });
}
console.error(err);
return problem(c, 500, "internal");
});