Validation & Errors
Zod schemas at boundaries. RFC 7807 problem details.
Your API is a contract. The moment a request crosses the boundary from the wild internet into your code, you can't trust any of it. The client said the body had a name string and an age number? Maybe. Maybe they sent { "name": null, "age": "twelve" }, or a 50MB JSON blob, or a SQL injection payload disguised as an email. Validation is how you turn untrusted JSON into typed, safe data. We'll use Zod.
The validate-at-the-boundary rule
Validate every input at the point it enters your system. Once data is past the door and into your business logic, it should already be typed and correct.
- Request body - validate after parsing JSON.
- URL params - validate
:idis a valid UUID or number. - Query strings - validate filters, pagination, sort keys.
- Headers - validate auth tokens, content-type, etc.
- Env vars - validate at startup so you fail fast.
Zod in 60 seconds
Zod is a TypeScript-first schema library. You describe the shape, Zod gives you a runtime parser and an inferred TS type.
npm install zodimport { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "member", "guest"]),
tags: z.array(z.string()).default([]),
});
// Infer the TypeScript type:
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "member" | "guest"; tags: string[] }One source of truth - schema and type stay in lockstep. Stop writing types and validators separately.
parse vs safeParse
Two ways to run a schema against data:
schema.parse(data)- returns the parsed value, or throws aZodErroron failure.schema.safeParse(data)- returns a discriminated union:{ success: true, data }or{ success: false, error }. No throws.
// Throw on failure
const user = UserSchema.parse(req.body); // throws ZodError if invalid
// Branch on failure (preferred at HTTP boundaries)
const result = UserSchema.safeParse(req.body);
if (!result.success) {
// result.error.issues is an array of field errors
return res.status(400).json(formatError(result.error));
}
const user = result.data; // typed and validatedUse safeParse in HTTP handlers - throwing across middleware boundaries is messy. Use parsein internal code where you want exceptions for "impossible" states.
RFC 7807: structured error responses
Don't invent your own error format. RFC 7807 (Problem Details for HTTP APIs) is a small standard with five well-known fields:
type- a URI that identifies the error type.title- human-readable short summary.status- HTTP status code.detail- human-readable explanation of this instance.instance- URI/ID of the specific occurrence.
Plus any extra fields you need. The content type is application/problem+json.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://example.com/errors/validation",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/users",
"errors": [
{ "path": "email", "message": "Invalid email address" },
{ "path": "age", "message": "Number must be greater than or equal to 0" }
]
}{ error: "..." } vs { message: "..." } vs { errors: [...] } and frontends suffer.Putting it together: typed POST /users in Hono
import { Hono } from "hono";
import { z, ZodError } from "zod";
const app = new Hono();
// 1. Define the schema once
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(120),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// 2. Shape errors as RFC 7807
function problemFromZod(err: ZodError, instance: string) {
return {
type: "https://api.example.com/errors/validation",
title: "Validation failed",
status: 400,
detail: "One or more fields are invalid.",
instance,
errors: err.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
};
}
// 3. Validate at the boundary
app.post("/users", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) {
return c.json(
{ type: "about:blank", title: "Malformed JSON", status: 400 },
400,
{ "Content-Type": "application/problem+json" }
);
}
const parsed = CreateUserSchema.safeParse(body);
if (!parsed.success) {
return c.json(problemFromZod(parsed.error, "/users"), 400, {
"Content-Type": "application/problem+json",
});
}
// 4. From here down, parsed.data is fully typed CreateUserInput
const input: CreateUserInput = parsed.data;
const user = await createUserInDb(input);
return c.json(user, 201);
});
declare function createUserInDb(input: CreateUserInput): Promise<unknown>;Validating URL params and queries
Path params and query strings come in as strings. Use Zod's .coerce to convert before validating.
const PaginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
const IdParamSchema = z.object({
id: z.string().uuid(),
});
app.get("/users", (c) => {
const q = PaginationSchema.safeParse({
limit: c.req.query("limit"),
offset: c.req.query("offset"),
});
if (!q.success) return c.json(problemFromZod(q.error, "/users"), 400);
// q.data is { limit: number; offset: number }
});
app.get("/users/:id", (c) => {
const p = IdParamSchema.safeParse({ id: c.req.param("id") });
if (!p.success) return c.json(problemFromZod(p.error, c.req.path), 400);
// p.data.id is a UUID string
});Pre-built integrations
Both Express and Hono have community middleware that wires Zod validation into the route registration step, so you don't write the same safeParse → format error dance in every handler.
// Hono: @hono/zod-validator
import { zValidator } from "@hono/zod-validator";
app.post(
"/users",
zValidator("json", CreateUserSchema, (result, c) => {
if (!result.success) {
return c.json(problemFromZod(result.error, "/users"), 400);
}
}),
async (c) => {
const data = c.req.valid("json"); // already typed
const user = await createUserInDb(data);
return c.json(user, 201);
}
);Common validation gotchas
- Trim before validating.
z.string().trim().min(1)beats fighting" "bug reports. - Use
.email()carefully.Zod's email regex is RFC-leaning. For real email verification, send a code. - Don't leak schema details in errors. "Password must contain uppercase, lowercase, number, symbol" is fine; "Field
passwordHash.saltfailed" tells attackers about your internals. - Set a body size limit. A 100MB JSON payload will eat your event loop. Frameworks usually let you cap it.
Quiz
Why validate inputs even when the client already validates?
Recap
- Validate every input at the boundary: body, params, query, headers, env.
- Define a Zod schema once;
z.infer<typeof X>gives you the matching TS type. - Prefer
safeParsein HTTP handlers,parsein internal code. - Return errors as RFC 7807 Problem Details (
application/problem+json). Include per-field issues for form errors. - Always validate twice: client for UX, server for security. Never trust a request just because your frontend "already checked."