All posts
5 min read

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.

securityarchitecturetoolingintermediate

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.flattenError or z.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