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

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

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.

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

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