Validation from First Principles
- Why you can't trust anything
- Parsing vs checking
- Zod in 5 minutes
- Primitives
- String refinements
- Number refinements
- Objects
- Arrays
- Enums
- Unions
- safeParse vs parse
- How Hectoday uses Zod
- The discriminated union
- Three sources of input
- Coercion: query strings are always strings
- Defaults
- Optional fields
- Validation issues
- Body parsing
- Reusing schemas
- Extracting TypeScript types from schemas
- What not to validate
- Summary
A guide for developers who trust req.body and hope for the best. Every example uses @hectoday/http, but the ideas apply to any server that accepts user input.
Why you can't trust anything
Your server receives data from the outside world. That data could be anything. A well-formed JSON object. An empty string. A 50MB payload of garbage. A carefully crafted string designed to break your database.
The type system can't help you here. TypeScript checks your code at compile time, but it has no idea what shows up at runtime. When you write:
interface CreateUser {
name: string;
email: string;
}TypeScript trusts you. It doesn't check that the request body actually has a name field, or that email is a real email address, or that neither field is 10,000 characters long. At runtime, body could be null, 42, or { name: [1, 2, 3], surprise: true }.
Validation is the bridge between the untyped outside world and the typed inside of your code. Once data passes validation, you can trust it.
Parsing vs checking
There are two approaches to validation:
Checking means verifying that data matches your expectations and reporting errors if it doesn't:
function validate(body: unknown): string[] {
const errors = [];
if (typeof body !== "object" || body === null) {
errors.push("Body must be an object");
return errors;
}
if (!("name" in body) || typeof body.name !== "string") {
errors.push("name must be a string");
}
if (!("email" in body) || typeof body.email !== "string") {
errors.push("email must be a string");
}
return errors;
}
const errors = validate(body);
if (errors.length > 0) {
// handle errors
}
// body is still `unknown` — TypeScript learned nothingAfter checking, you know the data is valid, but TypeScript doesn't. You still have to cast.
Parsing means transforming untyped data into typed data, or failing:
import { z } from "zod";
const CreateUser = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const result = CreateUser.safeParse(body);
if (!result.success) {
// result.error has the issues
}
// result.data is { name: string; email: string } — TypeScript knowsAfter parsing, the data is typed. No cast needed. TypeScript narrows the type from the schema.
Parsing is strictly better. You define the shape once, get both runtime validation and compile-time types from the same source.
Zod in 5 minutes
Zod is a schema library. You describe what data should look like, and Zod checks it at runtime.
Primitives
z.string(); // must be a string
z.number(); // must be a number
z.boolean(); // must be a boolean
z.null(); // must be null
z.undefined(); // must be undefined
z.unknown(); // anything, no validationString refinements
z.string().min(1); // at least 1 character
z.string().max(100); // at most 100 characters
z.string().email(); // valid email format
z.string().url(); // valid URL format
z.string().uuid(); // valid UUID format
z.string().regex(/^[a-z]+$/); // matches a patternNumber refinements
z.number().int(); // must be an integer
z.number().min(0); // 0 or greater
z.number().max(100); // 100 or less
z.number().positive(); // greater than 0Objects
z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});Every field is required unless marked .optional(). Extra fields are stripped by default.
Arrays
z.array(z.string()); // array of strings
z.array(z.number()).min(1).max(10); // 1-10 numbersEnums
z.enum(["admin", "user", "guest"]); // one of these exact stringsUnions
z.union([z.string(), z.number()]); // string or numbersafeParse vs parse
// safeParse: returns a result object, never throws
const result = schema.safeParse(data);
if (result.success) {
result.data; // typed
} else {
result.error; // ZodError with issues
}
// parse: returns the data or throws
const data = schema.parse(data); // throws ZodError on failureAlways use safeParse in servers. You don't want validation failures to throw — you want to decide what happens when validation fails.
How Hectoday uses Zod
You define schemas on the route. The framework runs safeParse for you and puts the result on c.input:
route.post("/users", {
request: {
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
},
resolve: (c) => {
if (!c.input.ok) {
// Validation failed. c.input.issues tells you what's wrong.
return Response.json({ error: c.input.issues }, { status: 400 });
}
// Validation passed. c.input.body is typed.
c.input.body.name; // string
c.input.body.email; // string
},
});The framework computes a fact: "this data does or doesn't match the schema." You decide what that fact means. Return 400? Return 422? Log it and continue with defaults? That's your choice.
The discriminated union
c.input is a discriminated union. This is a TypeScript pattern where one field determines the type of the rest.
When c.input.ok is true:
c.input.params; // typed from the params schema
c.input.query; // typed from the query schema
c.input.body; // typed from the body schema
c.input.issues; // [] (empty array)
c.input.failed; // [] (empty array)When c.input.ok is false:
c.input.params; // undefined
c.input.query; // undefined
c.input.body; // undefined
c.input.issues; // ValidationIssue[]
c.input.failed; // ("params" | "query" | "body")[]After checking c.input.ok, TypeScript narrows the type. Inside if (!c.input.ok), you get issues. After it, you get typed data. No casts.
if (!c.input.ok) {
// TypeScript knows: c.input.issues exists, c.input.body is undefined
return Response.json({ error: c.input.issues }, { status: 400 });
}
// TypeScript knows: c.input.body is { name: string; email: string }This is the "facts before decisions" philosophy. The framework computed the fact (valid or not). The if statement is the decision.
Three sources of input
An HTTP request can carry data in three places:
Path parameters — dynamic segments in the URL. /users/:id means id is extracted from the path.
route.get("/users/:id", {
request: {
params: z.object({ id: z.string().uuid() }),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
c.input.params.id; // string (UUID)
},
});Query parameters — key-value pairs after ? in the URL. /users?page=2&limit=10.
route.get("/users", {
request: {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
c.input.query.page; // number
c.input.query.limit; // number
},
});Request body — the payload, usually JSON. Sent with POST, PUT, PATCH.
route.post("/users", {
request: {
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
c.input.body.name; // string
c.input.body.email; // string
},
});Each schema is optional. Define only what you need. They're all validated before your handler runs.
Coercion: query strings are always strings
Query parameters arrive as strings. ?page=2 gives you "2", not 2. If your schema expects a number, plain z.number() fails because "2" is not a number.
z.coerce.number() converts the string to a number first, then validates:
z.coerce.number(); // "2" → 2 ✓
z.coerce.number().int(); // "2" → 2 ✓, "2.5" → 2.5 ✗
z.coerce.boolean(); // "true" → true, "false" → falseUse z.coerce for query parameters. Use plain z.string(), z.number() for JSON bodies (JSON already has real types).
Defaults
z.default() provides a fallback when the value is missing:
z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
sort: z.enum(["name", "created"]).default("created"),
});If the client sends ?page=3, you get { page: 3, limit: 20, sort: "created" }. Missing fields get their defaults. Present fields are validated normally.
Optional fields
.optional() means the field can be missing. .default() means the field gets a value when missing. They're different:
z.object({
name: z.string(), // required
bio: z.string().optional(), // can be missing (type: string | undefined)
role: z.enum(["admin", "user"]).default("user"), // always present (type: "admin" | "user")
});With optional, your code has to handle undefined. With default, the value is always there.
Validation issues
When validation fails, c.input.issues tells you exactly what went wrong:
interface ValidationIssue {
part: "params" | "query" | "body";
path: readonly string[];
message: string;
code?: string;
}Posting { name: "", email: "bad" } against a schema that requires name.min(1) and email.email():
[
{
"part": "body",
"path": ["name"],
"message": "String must contain at least 1 character(s)",
"code": "too_small"
},
{ "part": "body", "path": ["email"], "message": "Invalid email", "code": "invalid_string" }
]part tells you where (params, query, or body). path tells you which field. message is human-readable. code is machine-readable.
If multiple sources fail, issues from all of them appear together. c.input.failed tells you which parts failed: ["body"], ["params", "query"], etc.
Body parsing
When you define a body schema, the framework calls request.json() automatically. If the request body isn't valid JSON, c.input.ok is false with a special issue:
{ "part": "body", "path": [], "message": "Invalid JSON", "code": "invalid_json" }Without a body schema, the framework doesn't touch the body. You can read it yourself with c.request.json() or c.request.text() if you need a format other than JSON.
Important: the body is a stream and can only be read once. If the framework parsed it (because you defined a body schema), calling c.request.json() again will fail.
Reusing schemas
Schemas are just values. Extract them and reuse across routes:
const IdParams = z.object({ id: z.string().uuid() });
const Pagination = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
const CreateUser = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
const UpdateUser = CreateUser.partial(); // all fields optional
route.get("/users", {
request: { query: Pagination },
resolve: (c) => { ... },
})
route.get("/users/:id", {
request: { params: IdParams },
resolve: (c) => { ... },
})
route.post("/users", {
request: { body: CreateUser },
resolve: (c) => { ... },
})
route.patch("/users/:id", {
request: { params: IdParams, body: UpdateUser },
resolve: (c) => { ... },
}).partial() makes all fields optional — perfect for PATCH endpoints. .pick() and .omit() select or exclude fields. .extend() adds new fields to an existing schema. These are Zod's composition tools.
Extracting TypeScript types from schemas
Zod schemas produce TypeScript types:
const CreateUser = z.object({
name: z.string(),
email: z.string().email(),
});
type CreateUser = z.infer<typeof CreateUser>;
// { name: string; email: string }One definition, two uses: runtime validation and compile-time types. This is the single source of truth that keeps your types and your validation in sync.
What not to validate
Not everything needs a schema. Skip validation when:
The handler doesn't use the data. A health check endpoint has no input.
route.get("/health", {
resolve: () => Response.json({ status: "ok" }),
});You need the raw body. Webhooks often come with signatures that require the raw body for verification.
route.post("/webhook", {
resolve: async (c) => {
const raw = await c.request.text();
const signature = c.request.headers.get("x-signature");
if (!verifySignature(raw, signature)) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const payload = JSON.parse(raw);
// process payload
},
});No body schema here — you need the raw text for signature verification.
The format isn't JSON. File uploads (multipart/form-data), XML payloads, plain text. Zod validates structured data. For other formats, use c.request.formData(), c.request.text(), or a dedicated parser.
Summary
| Concept | What it means |
|---|---|
| Parsing | Transform untyped data into typed data, or fail |
safeParse |
Returns a result object, never throws |
c.input.ok |
Discriminated union: true means typed data, false means issues |
| Coercion | z.coerce.number() converts strings to numbers (for query params) |
| Default | z.default(value) fills in missing fields |
| Optional | .optional() allows missing fields (value is undefined) |
| Issues | Array of { part, path, message, code } describing what failed |
z.infer |
Extracts a TypeScript type from a Zod schema |
Define a schema. The framework parses the input. Check c.input.ok. Handle failure. Use the typed data. That's validation in Hectoday.