Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.
TypeScript types disappear at runtime. A handler typed as (body: CreateOrder) will happily receive { "quantity": "-5", "isAdmin": true } from an attacker — the type is a promise the compiler can't keep across a network boundary. Runtime validation closes that gap, and Zod is the most common way to do it in TypeScript: you write a schema once and get both the runtime check and the static type. (Form Validation Explained covers the concepts.)
The examples use Zod 4. Libraries such as Valibot and ArkType take the same approach, and many frameworks accept any of them through the shared Standard Schema interface.
Schema in, type out
import { z } from "zod"
export const CreateOrder = z.strictObject({
productId: z.uuid(),
quantity: z.int().min(1).max(100),
couponCode: z.string().trim().toUpperCase().max(32).optional(),
shipping: z.object({
country: z.string().length(2),
postcode: z.string().trim().min(3).max(10),
}),
})
export type CreateOrder = z.infer<typeof CreateOrder>
The schema is the single source of truth; the type is derived from it and can't drift.
Validate at every trust boundary
1. Request bodies
app.post("/api/orders", async (req, res) => {
const parsed = CreateOrder.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({ error: "invalid_input", fields: z.flattenError(parsed.error).fieldErrors })
}
const order = await createOrder(req.user.id, parsed.data) // typed and trusted
res.status(201).json(order)
})
Use safeParse in handlers (no exceptions for expected failures) and use only parsed.data afterwards — never the original req.body.
Unknown keys: strict or strip
This is the mass-assignment defence. By default, z.object strips unknown keys from the output, so { ..., "isAdmin": true } parses to an object without isAdmin. z.strictObject rejects unknown keys instead — better for APIs where extra fields indicate a client bug or probing. Either way, never spread raw input into a database write. (What Is CRUD?.)
2. Query strings and route params
Everything in a URL is a string. Use coercion deliberately:
const ListOrders = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(["pending", "paid", "refunded"]).optional(),
after: z.string().max(200).optional(), // opaque cursor
})
const q = ListOrders.parse(Object.fromEntries(new URL(req.url).searchParams))
Enums double as an allowlist for sort fields and filters — never pass a raw query value into ORDER BY. (SQL Injection Explained and API Pagination.)
Be careful with z.coerce.boolean() — it uses JavaScript truthiness, so the string "false" becomes true. Use an enum (z.enum(["true","false"]).transform(v => v === "true")) or Zod's string-boolean helper for query flags.
3. Webhooks and third-party API responses
Verify the signature first, then validate the payload shape you rely on. External APIs change; a schema turns "undefined is not a function" three layers deep into a clear error at the edge. (Handling Webhooks Reliably.)
4. Environment variables
Fail fast at startup instead of at the first request that needs a missing value:
const Env = z.object({
DATABASE_URL: z.url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "test", "production"]),
})
export const env = Env.parse(process.env)
(What Is an Environment Variable?.)
5. AI model output
If your app asks a model for structured data, validate what comes back before acting on it. Models can return malformed or unexpected JSON, and model output influenced by user content is untrusted input. Many AI SDKs accept a Zod schema directly for structured output — still keep the parse on your side.
6. Data read back from JSON columns and caches
Shapes stored months ago may not match today's type. Validate on read, or version the data. (Postgres JSONB.)
Share schemas between client and server
Put schemas in a module both sides import. The browser uses them for instant feedback (with form libraries like React Hook Form via a resolver, or with framework form helpers); the server re-validates with the same schema, because client checks can be bypassed.
Keep server-only rules — "email not already taken," "coupon still valid," "user owns this project" — out of the shared schema, or add them with async refinements on the server only.
Refinements and transforms
const DateRange = z.object({
from: z.iso.date(),
to: z.iso.date(),
}).refine((r) => r.from <= r.to, { message: "End date must be after start date", path: ["to"] })
Transforms (trim(), toUpperCase(), .transform(...)) normalise input so the rest of your code handles one canonical form. Keep them simple and predictable.
Errors people can use
- For APIs: a stable error code plus field-level messages (
z.flattenErrororz.treeifyError), with HTTP 400 or 422. (HTTP Status Codes Explained.) - For forms: map issues to fields and write human messages in the schema (
{ message: "Quantity must be between 1 and 100" }). - Don't echo raw input back into HTML error messages without escaping. (What Is XSS?.)
Limits that stop abuse
Validation is also resource protection. Put a max on every string and array, cap nesting, and set a request body size limit at the server or proxy — a schema can't help if a 500 MB JSON body is parsed before validation runs. (Reverse Proxies Explained.)
Make it the default for your AI tool
Add to CLAUDE.md: "Every route handler validates params, query, and body with a Zod schema from src/schemas, uses only the parsed output, and rejects unknown keys. Every string and array has a max length." Then ask for a sweep: "List every handler that reads req.body, req.query, or params without schema validation." (How to Write a CLAUDE.md.)
EasySpawn runs your backend as a real server where validation happens before your database is touched — and Claude Code can test each endpoint with the malformed requests an attacker would send. See how it works for AI-built apps or join the waitlist.
Related: TypeScript for AI-Generated Code · Designing a REST API · CSRF Explained
Keep reading
Soft Deletes and Audit Logs: Keeping History Without Making a Mess
Deleting rows is irreversible; hiding them has costs too. When to use soft deletes, how to implement them without leaking 'deleted' data (partial indexes, unique constraints, views, RLS), the privacy tension with erasure requests, and how to build an audit log with triggers or application events.
Designing a REST API That Won't Embarrass You Later
APIs are hard to change once clients depend on them. The conventions that keep a REST API predictable — resource naming, methods, status codes, errors, pagination, validation, idempotency, and versioning — with the specific mistakes AI-generated APIs tend to make.