webdev.complete
🛜 Building a REST API
🟢The Backend
Lesson 69 of 117
25 min

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 :id is 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.
The validate-twice rule
Client-side validation is for UX (instant feedback). Server-side validation is for security. Never skip server validation because the client "already checked." An attacker with curl bypasses your client entirely.

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.

bash
npm install zod
ts
import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.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 a ZodError on failure.
  • schema.safeParse(data) - returns a discriminated union: { success: true, data } or { success: false, error }. No throws.
ts
// 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 validated

Use safeParse in HTTP handlers - throwing across middleware boundaries is messy. Use parse in 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.

example response
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" }
  ]
}
Why this matters
Clients (especially typed ones) can write one error handler for every endpoint when responses are shaped predictably. Without a standard every team invents { error: "..." } vs { message: "..." } vs { errors: [...] } and frontends suffer.

Putting it together: typed POST /users in Hono

users.ts
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.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.

ts
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.

ts
// 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);
  }
);
Pick your battle
For ~3 routes, write the safeParse dance inline. Past that, install a validator middleware. The win is consistency: every endpoint emits the same shape on failure.

You are not locked into Zod

This lesson uses Zod because it is the one you will meet most often, but the library is now a swappable part. In 2025 the ecosystem agreed on Standard Schema, a tiny shared interface that a validator exposes under a ~standard property. Anything that speaks it can be handed to anything that consumes it.

  • Implementers: Zod, Valibot, ArkType, Effect Schema, and more recently Yup, Joi, and Mongoose.
  • Consumers: tRPC, TanStack Form and Router, Hono middleware, React Hook Form's resolvers, Elysia, T3 Env.

The practical reason to care is bundle size on the client. Valibot is built to tree-shake, so you ship only the validators you import, and it reports starting sizes under a kilobyte. ArkType optimises for runtime speed instead. Zod also ships a functional, tree-shakable variant as zod/mini inside the same package, which is often the smaller change than switching libraries. On a server, none of this matters much and Zod's ergonomics usually win.

Common validation gotchas

  • Trim before validating. z.string().trim().min(1) beats fighting " " bug reports.
  • Use z.email() carefully. Any email regex only proves the string is shaped like an address, not that it exists or that the person owns it. For that, send a code. Note the spelling: Zod 4 moved the string formats to the top level, so z.email(), z.uuid(), and z.url() replace the older z.string().email() chain, which is deprecated for removal.
  • Don't leak schema details in errors. "Password must contain uppercase, lowercase, number, symbol" is fine; "Field passwordHash.salt failed" 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

Quiz1 / 4

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 safeParse in HTTP handlers, parse in 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."
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.