
To use Zod for API validation, define a schema with z.object(), parse incoming requests with safeParse(), and infer your TypeScript types directly from the schema using z.infer<typeof Schema>. One schema then powers your validator, your request type, and your OpenAPI doc. That single source of truth is the entire reason Zod won this category.
TypeScript types disappear at runtime. The moment a JSON payload hits your handler, your beautiful interface CreateUser is gone, and what arrives is whatever the client felt like sending. Untrusted input remains the largest single source of production incidents we see across teams shipping fast: missing fields, wrong types, coerced strings, sneaky nulls. Zod closes that gap.
Two reasons Zod is the default in 2026:
If you only adopt one library this quarter for a Node, Bun, or edge runtime API, this is the one.
npm install zod
Zod 4 expects TypeScript 5.5 or later, with "strict": true in your tsconfig.json. Without strict mode, you lose the inference guarantees that make the library worth using.
// tsconfig.json (the flags that matter)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "Bundler"
}
}
import { z } from "zod";
That's the entire setup. No build plugins, no decorators, no codegen step.
Treat the four parts of an HTTP request as four separate schemas. Compose them at the route level.
// schemas/users.ts
import { z } from "zod";
export const CreateUserBody = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
role: z.enum(["owner", "member", "viewer"]).default("member"),
marketingOptIn: z.boolean().optional(),
});
export const ListUsersQuery = z.object({
// Query strings arrive as strings; coerce them.
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(25),
search: z.string().trim().max(120).optional(),
});
export const UserParams = z.object({
id: z.string().uuid(),
});
export const AuthHeaders = z.object({
authorization: z.string().regex(/^Bearer .+/),
"x-org-id": z.string().uuid(),
});
Two patterns earn their keep here. z.coerce.number() handles the fact that Express, Hono, and Next.js all hand you query strings as strings. z.string().trim() doubles as a transform; the parsed value already has whitespace removed, so your handler never has to remember.
export type CreateUserBody = z.infer<typeof CreateUserBody>;
export type ListUsersQuery = z.infer<typeof ListUsersQuery>;
Now the handler signature is a one-liner:
function createUser(body: CreateUserBody) { /* fully typed */ }
When a schema has transforms or defaults, the input shape and the output shape diverge. Use z.input<typeof S> for what callers send, and z.output<typeof S> (the same as z.infer<typeof S>) for what your handler receives after parsing. This is the bit most blog posts skip, and it's exactly what bites teams when they pass defaults to a coerced query schema.
parse throws a ZodError on failure. safeParse returns a discriminated result you can branch on without exceptions.
const parsed = CreateUserBody.safeParse(await req.json());
if (!parsed.success) {
return Response.json(formatZodError(parsed.error), { status: 400 });
}
const body = parsed.data; // typed as CreateUserBody
Rule of thumb: use safeParse at every external boundary (HTTP handler, queue consumer, webhook). Use parse only inside trusted code where a thrown error is genuinely unrecoverable (a config loader at boot, a test fixture). Throwing inside a request handler works, but it pushes error formatting into your global error filter and tends to leak raw issue arrays to clients.
A raw ZodError is dense, nested, and not safe to send as-is. Two production-friendly options:
Option A: a tiny formatter you own.
import { ZodError } from "zod";
export function formatZodError(error: ZodError) {
return {
type: "https://api.example.com/errors/validation",
title: "Validation failed",
status: 400,
errors: error.issues.map((i) => ({
field: i.path.join("."),
code: i.code,
message: i.message,
})),
};
}
That shape sticks close to RFC 7807 problem+json, which most API clients (and a few logging vendors) parse natively.
Option B: the zod-validation-error library.
import { fromZodError } from "zod-validation-error";
if (!parsed.success) {
const friendly = fromZodError(parsed.error);
return Response.json({ message: friendly.toString() }, { status: 400 });
}
zod-validation-error flattens nested issues into a single readable string per request. Use it when your API powers a UI that needs human-readable copy, not a structured error list.
The same schema slots into every popular framework. The table below shows what changes; the schema doesn't.
| Framework | Validator | Type Inference | OpenAPI Path |
|---|---|---|---|
| Next.js Route Handlers | schema.safeParse(await req.json()) | z.infer<typeof Body> | @asteasolutions/zod-to-openapi |
| tRPC | .input(schema) on the procedure | Built-in via the tRPC client | trpc-openapi adapter |
| Express | validate(schema) middleware | z.infer plus a typed req helper | @asteasolutions/zod-to-openapi |
| Fastify | fastify-type-provider-zod | Built-in via the type provider | @fastify/swagger + zod provider |
| Hono | @hono/zod-validator | Built-in via c.req.valid | @hono/zod-openapi |
// app/api/users/route.ts
import { CreateUserBody } from "@/schemas/users";
import { formatZodError } from "@/lib/format-zod-error";
export async function POST(req: Request) {
const parsed = CreateUserBody.safeParse(await req.json());
if (!parsed.success) {
return Response.json(formatZodError(parsed.error), { status: 400 });
}
// parsed.data is fully typed as CreateUserBody
const user = await db.user.create({ data: parsed.data });
return Response.json(user, { status: 201 });
}
import { z } from "zod";
import { publicProcedure, router } from "./trpc";
export const userRouter = router({
create: publicProcedure
.input(CreateUserBody)
.mutation(({ input }) => db.user.create({ data: input })),
});
Validation, types, and client codegen all flow from .input(schema). There is no second step.
import type { Request, Response, NextFunction } from "express";
import type { ZodSchema } from "zod";
export const validate =
(schema: { body?: ZodSchema; query?: ZodSchema; params?: ZodSchema }) =>
(req: Request, res: Response, next: NextFunction) => {
for (const key of ["body", "query", "params"] as const) {
const part = schema[key];
if (!part) continue;
const result = part.safeParse(req[key]);
if (!result.success) {
return res.status(400).json(formatZodError(result.error));
}
// overwrite with parsed/coerced value
(req as any)[key] = result.data;
}
next();
};
app.post("/users", validate({ body: CreateUserBody }), (req, res) => {
// req.body is typed if you augment Express Request
});
import Fastify from "fastify";
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
const app = Fastify().withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.post("/users", { schema: { body: CreateUserBody } }, async (req) => {
// req.body is typed automatically
return db.user.create({ data: req.body });
});
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
const app = new Hono();
app.post("/users", zValidator("json", CreateUserBody), (c) => {
const body = c.req.valid("json"); // typed as CreateUserBody
return c.json({ ok: true, body });
});
Hono's validator is the lowest-ceremony of the five, which is one of the reasons it's eaten Express's lunch on the edge.
Four Zod features that separate junior usage from senior usage.
Discriminated unions for variant payloads. When the shape depends on a type field, z.discriminatedUnion is faster and produces better error messages than z.union.
const Webhook = z.discriminatedUnion("type", [
z.object({ type: z.literal("payment.succeeded"), id: z.string(), amount: z.number() }),
z.object({ type: z.literal("payment.failed"), id: z.string(), reason: z.string() }),
z.object({ type: z.literal("subscription.canceled"), id: z.string(), at: z.coerce.date() }),
]);
Transforms for sanitization. They mutate the parsed value, which means your handler always receives the cleaned shape.
const Email = z.string().trim().toLowerCase().email();
Refinements for custom rules that pure schema can't express.
const Password = z.string().min(12).refine(
(p) => /[A-Z]/.test(p) && /[0-9]/.test(p),
{ message: "Must include an uppercase letter and a digit" }
);
Brand types to stop ID mix-ups at the type level.
const UserId = z.string().uuid().brand<"UserId">();
const OrgId = z.string().uuid().brand<"OrgId">();
type UserId = z.infer<typeof UserId>;
type OrgId = z.infer<typeof OrgId>;
function loadUser(id: UserId) { /* ... */ }
const orgId = OrgId.parse(req.params.id);
loadUser(orgId); // compile error: Argument of type 'OrgId' is not assignable to parameter of type 'UserId'
A brand is a phantom type. At runtime it's still a string. At compile time, the TypeScript checker refuses to mix them. This single pattern eliminates an entire class of "pass the wrong ID to the wrong query" bugs that show up in code reviews. While we're on the subject, the same care that produces strong validation produces strong code reviews; if your team is shipping AI-assisted PRs, our notes on doing code reviews effectively in 2026 walk through where humans should still slow down.
Hand-written OpenAPI drifts from code within a sprint. Zod's ecosystem turns the schema into the doc.
For Express and Next, @asteasolutions/zod-to-openapi registers schemas and emits an OpenAPI 3.1 document:
import { OpenAPIRegistry, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi";
const registry = new OpenAPIRegistry();
registry.register("CreateUserBody", CreateUserBody);
registry.registerPath({
method: "post",
path: "/users",
request: { body: { content: { "application/json": { schema: CreateUserBody } } } },
responses: { 201: { description: "Created" } },
});
const doc = new OpenApiGeneratorV31(registry.definitions).generateDocument({
openapi: "3.1.0",
info: { title: "API", version: "1.0.0" },
});
Hono and tRPC have first-party adapters (@hono/zod-openapi, trpc-openapi) that follow the same pattern. The point isn't the library; it's that your API contract, your validator, and your handler types now live in one file. When the schema changes, every downstream artifact changes with it.
This is the same instinct that makes API versioning correct in 2026 tractable: pick one source of truth, then make the tooling enforce it. Pair Zod with the rate-limiting and observability story in our guides on REST API design and rate limiting an API, and you have most of an enterprise-grade API surface in a weekend.
If you'd rather have a senior engineer do the migration than read your way to it, audit your stack with the Cadence Ship-or-Skip tool for a 5-minute honest grade on whether Zod plus OpenAPI generation is the right next move for you.
z.coerce on numeric query params. ?page=2 fails validation against z.number() because Express hands you a string. Use z.coerce.number().ZodError to clients. It contains internal paths and codes you don't want exposed. Always run it through a formatter.parse in a hot loop without try/catch. Throwing on every invalid record from a Kafka topic will pin a CPU. Use safeParse and route bad records to a dead-letter queue.z.infer. The duplication is the whole bug.refine returns a boolean. transform returns the new value. Mixing them produces confusing error messages and broken inference.Be honest about scope. Three cases where Zod adds friction without payoff:
JSON.parse plus a quick assertion is fine.If you're shipping anything with a public API, a webhook receiver, or a background job that reads from a queue, Zod earns its keep on day one.
Wiring Zod through five frameworks, branding your IDs, and generating OpenAPI from the same source isn't hard. It's just methodical, and most teams under-staff it because the founder is busy and the tech-lead is in meetings. A senior Cadence engineer ($1,500/week) typically lands a Zod-everywhere migration on a mid-sized API in 5 to 8 days, including OpenAPI generation, error formatting, and CI checks that fail when a route lacks a schema. Every Cadence engineer is AI-native by default, vetted on Cursor and Claude Code fluency in a voice interview before they unlock bookings, so they ship the schemas with AI-pair-programmed test coverage out of the gate.
Try it. Book a senior engineer on Cadence for a 48-hour free trial. They'll bring your API up to a Zod-validated, OpenAPI-documented baseline by Friday, with daily ratings and weekly billing. No notice period.
npm install zod. Confirm TypeScript 5.5+ and "strict": true in tsconfig.json.schemas/users.ts, write export const CreateUserBody = z.object({...}) for body, query, params, and headers as separate exports.CreateUserBody.safeParse(await req.json()) and branch on parsed.success. Return a 400 with a formatted error if it fails.export type CreateUserBody = z.infer<typeof CreateUserBody> and use it for handler arguments, downstream functions, and shared client code.validate(schema) for Express, zValidator("json", schema) for Hono, .input(schema) for tRPC, the type provider for Fastify, or call safeParse inline for Next.js Route Handlers.@asteasolutions/zod-to-openapi (or the adapter for your framework) and serve the generated document at /openapi.json. Your validator, your types, and your docs now ship from the same source.Zod. It infers TypeScript types directly from the schema; Yup needs separate type files and is slower to maintain on a typed codebase. Yup still has a place in pure JavaScript projects, but if you're on TypeScript, Zod's inference removes the duplication that Yup forces you to maintain by hand.
A smaller core (~2 KB gzipped), faster parsing on common shapes, a unified .check() API for combined refinements and transforms, cleaner issue codes for nicer error messages, and built-in JSON Schema conversion. Most v3 code runs on v4 with no changes; the migration guide in the Zod docs covers the few breaking renames.
Per-request validation runs well under a millisecond on typical payloads, far below network and database latency. Only deeply nested 1MB+ JSON starts to register, and even then a database round-trip dominates. If you measure and find Zod in your top-three flame-graph items, you're either parsing huge payloads or doing it on every middleware hop; cache the result on the request object.
Put them in a shared package (a TypeScript monorepo with pnpm workspaces, or a published @org/schemas package). Both the client and the server import the same z.object, both sides get the same z.infer type, and your form library (React Hook Form, TanStack Form) can re-use the same schema for client-side validation.
No. Zod replaces hand-written OpenAPI by generating it. Tools like @asteasolutions/zod-to-openapi, @hono/zod-openapi, or trpc-openapi turn your schemas into the spec, so docs never drift from validation. Clients in other languages still consume the OpenAPI document; you just stop hand-editing it.