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.coercetells 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, use1
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"]).
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()?