Cheatsheet and capstone
Everything in one place
We have covered a lot of ground. Primitives, objects, arrays, unions, transforms, refinements, coercion, composition, error formatting, and type inference. This lesson is a cheatsheet of every pattern and a complete, working contact form API that puts them all together.
The cheatsheet
Primitives
z.string()with.trim().min(1)for required text fieldsz.string().email()for email addressesz.number().int().positive()for IDs and countsz.number().min().max()for bounded values (ratings, prices)z.boolean()for flags and togglesz.enum()for predefined string values.optional()for non-required fields.default()for fields with sensible defaults.nullable()when null is a valid value
Objects and arrays
z.object()for structured data- Nested objects for addresses, preferences, metadata
z.array()with.min(1)or.nonempty()for required listsz.discriminatedUnion()for tagged object variants
Transforms and refinements
.transform()to normalize data (trim, lowercase, parse).refine()for custom validation (password rules, date ranges).superRefine()when multiple errors must be reported at oncez.coercefor query parameters (string to number/boolean)
Composition
.pick()/.omit()to derive create/update schemas from a base.partial()for update schemas (all fields optional).merge()for adding shared fields (pagination, timestamps).extend()for adding new fields to existing schemas
Practice
- Schemas in a shared library (
src/schemas/) - Types inferred with
z.infer<>(no separate interfaces) - All three request sources validated (body, query, params)
- Error formatting with
.flatten()for field-level errors - Custom error messages for user-facing fields
Common mistakes to watch for
No .trim() on string inputs. " " (whitespace only) passes min(1). Always trim before checking length.
No coercion on query params. ?page=2 is the string "2". z.number() rejects it. Use z.coerce.number().
Using .optional() when you mean .default(). Optional returns undefined when missing. Default returns a value. If your code cannot handle undefined, use .default().
Forgetting to check c.input.ok. The schema validates, but if you skip the ok check, c.input.body might be unusable on validation failure.
Not using .flatten() for API errors. Returning raw ZodError gives clients an unusable error object. .flatten() gives field-level errors they can display.
Separate interfaces and schemas. Defining a Contact interface and a ContactSchema separately means they can drift. Use z.infer to derive the type from the schema.
The complete contact form API
Here is the finished version, every file in the project. Compare this to the unvalidated version from the project setup lesson. Every field is validated. Every error is formatted. Types are inferred. Schemas are reusable.
// src/db.ts
export interface Contact {
id: string;
name: string;
email: string;
phone: string | null;
subject: string;
message: string;
createdAt: string;
}
export const contacts: Contact[] = []; // src/schemas/contact.ts
import { z } from "zod/v4";
export const CreateContact = z.object({
name: z.string().trim().min(1, { message: "Name is required" }).max(100),
email: z.string().trim().toLowerCase().email({ message: "Valid email required" }),
phone: z.string().trim().min(7).max(20).optional(),
subject: z.enum(["general", "support", "sales", "feedback"]).default("general"),
message: z
.string()
.trim()
.min(10, { message: "Message must be at least 10 characters" })
.max(5000),
tags: z.array(z.string().trim().min(1)).max(5).optional(),
});
export type CreateContactInput = z.infer<typeof CreateContact>;
export const ContactResponse = CreateContact.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
export type ContactResponseType = z.infer<typeof ContactResponse>;
// Query params for listing
export const ContactQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
subject: z.enum(["general", "support", "sales", "feedback"]).optional(),
}); // src/app.ts
import { z } from "zod/v4";
import { setup, route } from "@hectoday/http";
import { CreateContact, ContactQuery } from "./schemas/contact.js";
import { contacts } from "./db.js";
function formatErrors(error: z.ZodError) {
return {
error: {
code: "VALIDATION_ERROR",
message: "Invalid input",
fields: error.flatten().fieldErrors,
},
};
}
export const app = setup({
routes: [
route.post("/contacts", {
request: { body: CreateContact },
resolve: (c) => {
if (!c.input.ok) {
return Response.json(formatErrors(c.input.error), { status: 400 });
}
const { name, email, phone, subject, message } = c.input.body;
const contact = {
id: crypto.randomUUID(),
name,
email,
phone: phone ?? null,
subject,
message,
createdAt: new Date().toISOString(),
};
contacts.push(contact);
return Response.json(contact, { status: 201 });
},
}),
route.get("/contacts", {
request: { query: ContactQuery },
resolve: (c) => {
if (!c.input.ok) {
return Response.json(formatErrors(c.input.error), { status: 400 });
}
const { page, limit, subject } = c.input.query;
const offset = (page - 1) * limit;
let filtered = contacts;
if (subject) {
filtered = contacts.filter((ct) => ct.subject === subject);
}
const sorted = [...filtered].reverse();
const items = sorted.slice(offset, offset + limit);
const total = filtered.length;
return Response.json({
items,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
});
},
}),
],
}); // src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); Start the server with npm run dev and test it:
# Create a contact
curl -X POST http://localhost:3000/contacts \
-H "Content-Type: application/json" \
-d '{"name": "Ada Lovelace", "email": "[email protected]", "message": "Hello from the capstone!"}'
# List contacts
curl http://localhost:3000/contacts
# Try invalid data -- returns structured errors
curl -X POST http://localhost:3000/contacts \
-H "Content-Type: application/json" \
-d '{"name": "", "email": "not-an-email"}' Look at how far we have come from the unvalidated version. Back then, c.input.body as any let anything through. Now every field is validated, trimmed, and normalized. Invalid data gets a structured error response. Valid data is fully typed. The schemas live in a shared library and can be reused in tests, documentation, and other routes.
Challenges
Challenge 1: Add a PATCH /contacts/:id endpoint. Use CreateContact.partial() for the body. Validate the path param as a UUID.
Challenge 2: Add cross-field validation. If subject is “support”, message must be at least 50 characters. Use .refine().
Challenge 3: Add a contact search endpoint. Accept a q query parameter (string, min 2 chars). Search name, email, and message fields.
What is the most important Zod practice for API development?