hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

Validating API requests

Three places data hides in a request

An API request carries data in three places, and each one needs validation:

Body is the JSON sent with POST, PUT, or PATCH requests. This is the main data payload, like a contact form submission or a user profile update.

Query parameters are the key-value pairs in the URL like ?page=2&sort=title. These control filtering, pagination, and sorting.

Path parameters are segments of the URL itself, like /books/:id. These identify which resource you are talking about.

Each source behaves differently. Bodies are structured JSON where types are preserved (numbers are numbers, booleans are booleans). Query parameters and path parameters are always strings because they are part of the URL. Zod handles all three, but with slightly different approaches.

Validating the body

Hectoday HTTP integrates with Zod directly through the request option on routes:

import { route } from "@hectoday/http";
import { CreateContact } from "../schemas/contact.js";

route.post("/contacts", {
  request: { body: CreateContact },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json(
        {
          error: "Validation failed",
          details: c.input.issues,
        },
        { status: 400 },
      );
    }

    // c.input.body is typed and validated
    const { name, email, phone, subject, message } = c.input.body;
    // ... create contact
  },
});

Let’s walk through what happens. The framework receives the request, parses the JSON body, and runs it through the CreateContact Zod schema before your handler executes. If validation fails, c.input.ok is false and c.input.issues contains the field-level errors. If validation passes, c.input.body is the validated, fully typed data.

Your handler never touches raw, unvalidated input. By the time you destructure c.input.body, every field has been checked.

Validating query parameters

Query parameters need coercion because they arrive as strings. Remember z.coerce from the Preprocessing lesson? This is where it earns its keep:

import { z } from "zod/v4";

const BookQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  genre: z.string().optional(),
  sort: z.enum(["title", "date", "rating"]).default("title"),
});

route.get("/books", {
  request: { query: BookQuery },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json(
        { error: "Invalid query parameters", details: c.input.issues },
        { status: 400 },
      );
    }

    const { page, limit, genre, sort } = c.input.query;
    // page is number (coerced from string "2")
    // limit is number with default 20
    // genre is string | undefined
    // sort is "title" | "date" | "rating"
  },
});

The string "2" from ?page=2 gets coerced to the number 2. Missing parameters get their defaults. Invalid values fail with clear errors. Without this, you would be scattering parseInt calls and default checks throughout your handler.

[!NOTE] The Preprocessing and Coercion lesson introduced z.coerce. This is where it shines. Every query parameter arrives as a string and must be converted to the correct type.

Validating path parameters

Path parameters are extracted from the URL pattern:

const BookParams = z.object({
  id: z.string().uuid(),
});

route.get("/books/:id", {
  request: { params: BookParams },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ error: "Invalid book ID" }, { status: 400 });
    }

    const { id } = c.input.params;
    const book = books.find((b) => b.id === id);
    if (!book) return Response.json({ error: "Not found" }, { status: 404 });
    return Response.json(book);
  },
});

The path parameter id is validated as a UUID. If someone requests /books/not-a-uuid, validation fails before the lookup ever runs. This prevents wasted work and ensures the handler only receives well-formed input.

All three together

A single route can validate all three sources at once:

route.put("/books/:id", {
  request: {
    params: z.object({ id: z.string().uuid() }),
    query: z.object({ replace: z.coerce.boolean().default(false) }),
    body: UpdateBookSchema,
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json(
        { error: "Validation failed", details: c.input.issues },
        { status: 400 },
      );
    }

    const { id } = c.input.params; // validated UUID
    const { replace } = c.input.query; // coerced boolean
    const updates = c.input.body; // validated partial book
    // ...
  },
});

Params, query, and body are all validated before the handler runs. If any source fails, c.input.ok is false and c.input.issues tells you which source and which field had the problem. Your business logic only runs with clean, validated, correctly typed data.

This is the validation layer that sits between the internet and your application code. Everything that comes in gets checked. Everything that passes is safe to use.

Next, we will look at how to format those validation errors into responses that clients can actually use.

Exercises

Exercise 1: Add body validation to the POST /contacts route using request: { body: CreateContact }. Test with valid and invalid data.

Exercise 2: Add query parameter validation with coercion. Parse ?page=2&limit=50. Verify the values are numbers.

Exercise 3: Add path parameter validation for a UUID. Request /books/not-a-uuid. Verify a 400 error before the database is queried.

Why do query parameters need z.coerce but request bodies do not?

← Reusable schemas Error formatting →

© 2026 hectoday. All rights reserved.