hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses URL mastery with @hectoday/http

Understanding URLs

  • What is a URL?
  • The URL constructor
  • URL properties deep dive

URLSearchParams

  • URLSearchParams basics
  • Modifying search params
  • Iterating over params
  • URL and SearchParams together

Encoding

  • Encoding and special characters

Inside @hectoday/http

  • How @hectoday/http parses queries
  • Routing and URL patterns
  • The Request object and URLs
  • Building API URLs
  • Input validation and query schemas

Putting it all together

  • Capstone: bookmarks API

Input validation and query schemas

Everything we’ve built so far has a problem. Query string values always arrive as strings. Path parameters are strings. Even when a user sends ?page=3, your handler receives "3", not the number 3. You’d have to manually convert "3" to a number, check that "true" actually means true, handle missing values, validate ranges. That gets tedious fast, and one missed check can cause a bug.

You already know Zod from the earlier course. Now let’s see how @hectoday/http uses it to solve this problem automatically.

The problem: everything is a string

Remember: URLSearchParams.get() always returns a string, and parseQuery() also returns strings (or arrays of strings).

So when a user visits /products?page=3&inStock=true, your handler receives:

{ page: "3", inStock: "true" }
//       ↑              ↑
//    string!        string! (not a boolean)

What do you think happens if you write if (page > 10) when page is the string "3"? JavaScript will coerce the string to a number for the comparison, so it might appear to work. But this is fragile. What if someone sends ?page=abc? Now you’re comparing "abc" > 10, which is always false. No error, just wrong behavior. Silently.

Zod to the rescue

@hectoday/http uses Zod (specifically zod/v4) to define schemas that describe what shape your data should be. The framework validates the incoming data against these schemas automatically.

You define schemas in the route’s request config:

import { route } from "@hectoday/http";
import { z } from "zod/v4";

route.get("/products", {
  request: {
    query: z.object({
      page: z.coerce.number().min(1).default(1),
      limit: z.coerce.number().min(1).max(100).default(20),
      category: z.string().optional(),
    }),
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ errors: c.input.issues }, { status: 400 });
    }

    const { page, limit, category } = c.input.query;
    // page is a number (not a string!)
    // limit is a number with a default of 20
    // category is string | undefined

    return Response.json({ page, limit, category });
  },
});

Let’s unpack what’s happening. The request.query field contains a Zod schema that describes the expected query parameters. z.object({...}) says “I expect an object with these fields.” Each field has its own validation rules.

z.coerce.number().min(1).default(1) is a chain of four things:

  • z.coerce tells Zod to try converting the input to the target type first
  • .number() says the target type is a number
  • .min(1) means the number must be at least 1
  • .default(1) means if the parameter is missing entirely, use 1

When a request arrives with ?page=3, Zod takes the string "3", coerces it to the number 3, checks that it’s at least 1, and passes it through. If someone sends ?page=-5, the validation fails because -5 is less than 1.

What z.coerce.number() does

This is worth highlighting because it solves the core problem. The raw query string value "3" is a string. z.coerce.number() tells Zod: “Take whatever you get, try to turn it into a number, then validate it.” So "3" becomes the number 3.

Without coerce, Zod would reject "3" because it expects an actual number type and got a string. With query strings, you almost always want z.coerce.

The three input types

@hectoday/http validates three categories of input, all accessible from c.input:

1. params: dynamic path segments

route.get("/users/:id", {
  request: {
    params: z.object({
      id: z.coerce.number().int().positive(),
    }),
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ errors: c.input.issues }, { status: 400 });
    }
    // c.input.params.id is a number, guaranteed to be a positive integer
    return Response.json({ userId: c.input.params.id });
  },
});

Without a schema, params is Record<string, string>, meaning raw strings from the URL path. With a schema, Zod transforms and validates them. The string "42" becomes the number 42, and values like "abc" or "-1" are rejected.

2. query: the query string

route.get("/search", {
  request: {
    query: z.object({
      q: z.string().min(1),
      tags: z.array(z.string()).optional(),
    }),
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ errors: c.input.issues }, { status: 400 });
    }
    return Response.json(c.input.query);
  },
});

Without a schema, query is the raw output of parseQuery(): an object where values are strings, arrays of strings, or undefined. With a schema, you get validated, typed data.

3. body: the request body

route.post("/users", {
  request: {
    body: z.object({
      name: z.string().min(1),
      email: z.string().email(),
      age: z.number().int().min(0).optional(),
    }),
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ errors: c.input.issues }, { status: 400 });
    }
    return Response.json({ created: c.input.body }, { status: 201 });
  },
});

The framework automatically reads the request body as text, parses it as JSON, and validates it against your schema. Notice that the body schema uses z.number() without coerce. That’s because JSON bodies have real types. The number 25 in JSON is already a number, not the string "25". Coercion is mainly needed for query strings and path params where everything starts as a string.

The c.input object

After validation, c.input has this shape:

When validation succeeds

{
  ok: true,
  params: { ... },   // validated params (or raw if no schema)
  query: { ... },    // validated query (or raw if no schema)
  body: { ... },     // validated body (or raw/undefined)
  issues: [],        // empty array
  failed: [],        // empty array
}

When validation fails

{
  ok: false,
  params: undefined,
  query: undefined,
  body: undefined,
  issues: [
    {
      part: "query",
      path: ["page"],
      message: "Expected number, received string",
      code: "invalid_type",
    },
  ],
  failed: ["query"],
}

ok is the first thing you should check. If it’s true, all your validated data is available on params, query, and body. If it’s false, those fields are all undefined and the issues array tells you exactly what went wrong. The failed array lists which parts failed (e.g., ["query"] or ["params", "body"]).

⚠ Warning

Always check c.input.ok before accessing params, query, or body. If validation failed, those fields are undefined. If any part fails, all parts become undefined. The issues array tells you exactly what went wrong.

Validation order

The framework validates in this order: params then query then body.

The body is only read and parsed if a body schema is defined. If there’s no body schema, the raw body is never consumed. This is efficient because reading the body is an async operation, and you don’t want to do it unless you need to.

Handling invalid JSON bodies

What happens if someone sends a request with malformed JSON, like {broken? The framework catches the parse error and reports it:

{
  ok: false,
  issues: [
    {
      part: "body",
      path: [],
      message: "Invalid JSON",
      code: "invalid_json",
    },
  ],
  failed: ["body"],
}

This happens before Zod validation. If JSON parsing fails, the body schema is never run. There’s no point in validating the structure of something that couldn’t even be parsed.

You now have all the pieces: URL parsing, routing, request/response handling, and input validation. In the final lesson, we’ll put everything together by building a complete API from scratch.

Why do query schema fields usually need z.coerce.number() instead of just z.number()?

← Building API URLs Capstone: bookmarks API →

© 2026 hectoday. All rights reserved.