cheat sheet
zod
Package-level reference for zod on npm — install variants, v4 breaking changes (looseObject, mini build), companion packages, and alternatives.
zod
What it is
zod is a TypeScript-first runtime validator by Colin McDonnell. You define a schema (z.object({ ... })), and Zod gives you (a) a runtime parser (schema.parse(input)) and (b) a statically-inferred TS type (z.infer<typeof schema>) — guaranteed to stay in sync because they come from the same source.
It's zero-dependency, ships dual ESM+CJS, and has become the de-facto validator for tRPC, t3-env, React Hook Form, OpenAPI generators, and most modern API boundaries on Node, edge runtimes, and the browser.
Install
# npm / pnpm / yarn / bun
npm install zod
pnpm add zod
yarn add zod
bun add zod
Output: runtime dep — Zod ships in your prod bundle.
# v4: opt into the smaller "mini" build for tree-shake-sensitive bundles
import { z } from "zod/mini";
Output: ~50% smaller import for the same API surface — at the cost of dropping some method chaining (mini exposes functions like z.parse(schema, input) instead of schema.parse(input)).
# Common companions
npm install zod-validation-error # human-readable error messages from ZodError
npm install zod-to-json-schema # emit JSON Schema from a Zod schema
npm install @hookform/resolvers # bridge to React Hook Form
Output: companion packages plug into Zod's output types without monkey-patching.
Versioning & Node support
- Current major line is
4.x(stable since late 2025 — landedzod/mini, top-level functions, dropped chainable.passthrough(), narrowersafeParsereturn type, faster runtime).3.xis now legacy; both lines remain published and many ecosystem packages still pin to^3while they migrate. - Pure JS with type definitions; runs on any modern runtime — Node 18+, Bun, Deno, Cloudflare Workers, browsers (ES2020+ baseline).
- Dual ESM + CJS publishing via conditional exports.
- Always a runtime dependency — your prod code calls
parse/safeParse. - Strict semver — major bumps remove deprecated methods.
Package metadata
- Maintainer: Colin McDonnell (
@colinhacks) + core contributors - Project home: github.com/colinhacks/zod
- Docs: zod.dev
- npm: npmjs.com/package/zod
- License: MIT
- First released: March 2020
- Downloads: ~30 million per week — top-tier package on npm.
Peer dependencies & extras
Zod is zero-dependency. The ecosystem extends through companion packages:
| Package | Purpose |
|---|---|
zod-validation-error | Pretty-print ZodError into one human-readable message — standard for API responses. |
zod-to-json-schema | Emit JSON Schema from Zod. Used for OpenAPI generators, MCP tool schemas, JSON-Schema-driven UIs. |
zod-fetch / @better-fetch/fetch | Typed fetch wrappers that validate response shapes against Zod schemas. |
@hookform/resolvers | React Hook Form integration — zodResolver(schema). |
t3-env / @t3-oss/env-nextjs | Type-safe process.env parsing with Zod schemas. |
trpc (@trpc/server) | tRPC accepts Zod schemas directly as input(...) validators — the canonical pairing. |
zod-prisma-types / prisma-zod-generator | Auto-generate Zod schemas from schema.prisma. |
zod-openapi / @asteasolutions/zod-to-openapi | OpenAPI 3 emitters. |
valibot-zod-codec and similar bridges | Cross-validator interop. |
Alternatives
| Library | Trade-off |
|---|---|
| valibot | Modular API (object({ ... }) instead of z.object(...)) — tree-shakes to ~1 KB for small schemas, vs Zod's ~10 KB minimum. Same author philosophy; newer, smaller ecosystem. Pick for bundle-size-critical clients. |
| arktype | TS-syntax-like schemas (type({ name: "string" })) compiled to fast validators. Closest to a "TS-as-runtime-types" feel. Growing ecosystem; smaller than Zod's. |
| yup | The older "schema first" validator (pre-Zod). Less type-safe inference, more JS-y. Still common in legacy Formik projects. |
| joi | Hapi's validator. Excellent runtime; type inference much weaker than Zod. Stronger choice for plain-JS Node services. |
| ajv | JSON-Schema validator. Pick when your contract is JSON Schema (OpenAPI consumers, cross-language); compose with zod-to-json-schema. |
| superstruct | Older lightweight option. Smaller ecosystem; still serviceable for tiny schemas. |
Common gotchas
v4breaking changes are real. The chainable.passthrough()is gone — usez.looseObject({ ... })for the same behaviour..strict()and.strip()similarly moved. Many tutorials and Stack Overflow answers still targetv3syntax that throws onv4.safeParsereturn-type narrowing tightened inv4.result.success === truenarrows to{ success: true; data: T };falsenarrows to{ success: false; error: ZodError }. Older code that destructured{ data, error }unconditionally now seesdatatyped asT | undefined— TS flags it; fix with the narrowing check.z.coerce.*runsString()/Number()/Date()blindly.z.coerce.number().parse("abc")returnsNaN, then fails the schema with "Expected number, received nan" — confusing because it looks like the coerce should have errored. Avoidcoercefor user input; use.transform()with explicit parsing..optional()vs.nullable()vs.nullish()..optional()=T | undefined;.nullable()=T | null;.nullish()=T | null | undefined. JSON inputs from APIs usually delivernull, notundefined— pick.nullable()or.nullish()to match.refineerrors lose the field path. Errors from.refine(fn, msg)attach at the root unless you set{ path: ["field"] }in the second argument. The dev-time symptom isZodError: ["Invalid input"]with no indication of which field.z.inferis structural — extends quietly. Adding a field to a schema silently widens everyz.infer<>consumer's type. Plan major schema changes the way you would a public TS interface change.- Bundle size on the browser. A single
import { z } from "zod"pulls ~10 KB gzipped even if you only usez.string(). For client-side bundles usezod/mini(v4) or migrate the smallest schemas tovalibot.
Real-world recipes
The patterns that come up at every API and form boundary in real Zod code.
Validate an HTTP request body
import { z } from "zod";
const CreateUserBody = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(13).optional(),
});
export async function POST(req: Request) {
const json = await req.json();
const result = CreateUserBody.safeParse(json);
if (!result.success) {
return Response.json({ errors: result.error.issues }, { status: 400 });
}
// result.data is typed as { email: string; name: string; age?: number }
const user = await createUser(result.data);
return Response.json(user);
}
Parse env vars with type safety
import { z } from "zod";
const Env = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
FEATURE_FLAGS: z.string().default("").transform((s) => s.split(",").filter(Boolean)),
});
export const env = Env.parse(process.env);
// ^? { DATABASE_URL: string; PORT: number; NODE_ENV: ...; FEATURE_FLAGS: string[] }
Call once at module load; fail fast on missing/invalid config.
Discriminated union
const Event = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("scroll"), delta: z.number() }),
z.object({ type: z.literal("key"), key: z.string() }),
]);
type Event = z.infer<typeof Event>;
function handle(e: Event) {
if (e.type === "click") {
console.log(e.x, e.y); // TS knows x and y exist
}
}
Faster than a plain union — Zod uses the discriminator to pick the branch in O(1) rather than trying each.
Recursive schemas with z.lazy
type Category = { name: string; children: Category[] };
const Category: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(Category),
})
);
The annotation breaks the type-cycle so TS doesn't infer any.
Transform + refine pipeline
const ISODate = z.string()
.regex(/^\d{4}-\d{2}-\d{2}/)
.transform((s) => new Date(s))
.refine((d) => !isNaN(d.getTime()), { message: "Invalid date" });
const result = ISODate.parse("2026-05-31"); // Date object
Order matters: validations run left-to-right, transforms feed the next link.
Branded types for ID safety
const UserId = z.string().uuid().brand<"UserId">();
const PostId = z.string().uuid().brand<"PostId">();
type UserId = z.infer<typeof UserId>; // string & z.BRAND<"UserId">
function getUser(id: UserId) { /* ... */ }
const id = UserId.parse(input);
getUser(id); // OK
getUser("raw-string" as any); // TS error — not branded
Production deployment
Zod is a runtime dep; it ships with your app. The deployment concerns are bundle size and error-message hygiene.
Bundle size on the browser
- Full Zod (
zod) — ~10 KB gzipped minimum, even for az.string()schema. zod/mini(v4) — ~3-4 KB gzipped, ~50% smaller. Same validation surface; methods become standalone functions.
// zod/mini — functional form
import { z, parse, safeParse } from "zod/mini";
const Schema = z.object({ name: z.string() });
const result = parse(Schema, input); // not Schema.parse(input)
The mini build is preferred for client bundles where every KB matters; the full build is fine for server / edge / Node where ~10 KB is noise.
Sanitise error responses
ZodError.issues contains the raw paths and codes — fine internally, but exposing on a public API leaks schema structure. Format with zod-validation-error:
import { fromZodError } from "zod-validation-error";
const result = Schema.safeParse(input);
if (!result.success) {
return Response.json({ error: fromZodError(result.error).message }, { status: 400 });
}
Output: "Validation error: email: Invalid email; age: Number must be greater than 12".
Edge runtime compatibility
Zod is pure JS — runs on Workers, Vercel Edge, Deno, Bun. No platform-specific code, no I/O.
Performance tuning
Zod parses input top-down. Schema depth and .refine count dominate runtime.
parse vs safeParse
parse throws on failure; safeParse returns a discriminated result. The throw/catch path is slower under failure load — for hot paths that frequently see bad input (rate-limited APIs, fuzzed endpoints), safeParse is faster overall.
.strict() cost
.strict() checks for unknown keys; adds an O(keys) pass per object. For deep, hot schemas, prefer .strip() (the default) and validate explicit fields.
Deep schemas — limit recursion
A 10-level deep z.object with many fields can take milliseconds per parse. Flatten the schema where possible; pre-validate sub-trees independently and stitch.
Pre-compile lookups
z.discriminatedUnion(...) pre-builds a discriminator map at schema creation. Use it instead of z.union(...) whenever a literal field identifies the branch — 5-10× faster on parsing.
zod/mini perf
zod/mini removes some inheritance and method-chain overhead in v4. Wins are smaller for parsing (~10-20%) than for bundle size, but free if you're already on it.
ESM/CJS interop & bundling
Zod publishes dual ESM + CJS via conditional exports. Both forms work:
import { z } from "zod"; // ESM
const { z } = require("zod"); // CJS
The zod/mini subpath requires modern bundler support for the exports map. Older Webpack 4 chokes; upgrade to 5+ or use the full build.
Tree-shaking: the full build doesn't tree-shake well — every z.* factory pulls in shared internals. The mini build is designed for tree-shaking and emits only what you import.
Version migration guide
This is the migration most teams need to do in 2025-2026. Zod v4 stabilised in late 2025, and many ecosystem packages still pin ^3 while they catch up.
v3 → v4 — the big break
| Change | v3 | v4 |
|---|---|---|
.passthrough() | z.object({}).passthrough() | z.looseObject({}) |
.strict() | z.object({}).strict() | z.strictObject({}) (chain .strict() still works) |
.strip() | z.object({}).strip() | default — no method needed |
safeParse return type | { success, data?, error? } | discriminated union — must narrow on success |
| Default object mode | strip | strip (unchanged) |
| Top-level functions | only .method() chains | parse(schema, input), safeParse(schema, input) available |
zod/mini build | doesn't exist | new — ~50% smaller, functional API |
.transform() chaining | returns ZodEffects | returns ZodPipeline — narrower return type |
.refine() async | parseAsync required | unchanged but type-narrowing improved |
z.coerce.* | quirky .parse("abc") returns NaN | unchanged; still avoid for user input |
| Brand syntax | .brand<"Tag">() | .brand<"Tag">() (unchanged) |
| Error format | error.errors | error.issues (was already preferred in v3 but enforced in v4) |
Mechanical fixes
// v3
const Loose = z.object({ name: z.string() }).passthrough();
// v4
const Loose = z.looseObject({ name: z.string() });
// v3 — destructure freely
const { data, error } = Schema.safeParse(input);
console.log(data?.email);
// v4 — must narrow
const result = Schema.safeParse(input);
if (result.success) {
console.log(result.data.email); // typed as the inferred shape
} else {
console.log(result.error.issues);
}
// v3
import { z } from "zod";
const r = Schema.safeParse(input);
// v4 (optional but recommended for tree-shaking)
import { z, safeParse } from "zod/mini";
const r = safeParse(Schema, input);
Ecosystem catch-up
Some libraries still pin zod@^3:
- tRPC: v11 supports v4; older tRPC pinned v3.
- @hookform/resolvers: bumped to v4-compatible in late 2025.
zod-validation-error: dual-supports both.- OpenAPI generators (
@asteasolutions/zod-to-openapi,zod-openapi): usually trail by a few weeks.
When upgrading, audit npm ls zod to find any v3 transitive pins; once flagged, either upgrade the pinning lib or pin Zod itself to v3 until they catch up. Running both zod@3 and zod@4 in the same bundle is possible but doubles bundle size — avoid.
One-shot migration script
# Find every passthrough/.strict()/.strip() call
rg "\.passthrough\(\)|\.strict\(\)|\.strip\(\)" --type ts
# Find safeParse destructuring
rg "const \{ data, error \} = \w+\.safeParse" --type ts
# Find direct error.errors access
rg "\.error\.errors" --type ts
Output:
src/api/users.ts:42: return UserSchema.passthrough().parse(input);
src/api/orders.ts:18: const { data, error } = OrderSchema.safeParse(body);
src/api/orders.ts:21: return error.errors.map(e => e.message);
Most teams can land the v3→v4 diff in 1-3 commits: (1) rename .passthrough() → looseObject, (2) narrow safeParse results, (3) move to zod/mini where bundle size matters.
Security considerations
- Validate untrusted input at every boundary. HTTP bodies, query params, form data, file uploads, IPC messages, env vars. Never trust JSON shape after
JSON.parse. - Regex DoS via
.regex(...). Catastrophic backtracking in user-supplied regex patterns can hang the event loop. Pin the regex pattern; never accept it from input. Test withsafe-regexor audit manually. .transform()runs arbitrary code. A transform that callsJSON.parse(s)on attacker-controlled string can throw — wrap or pre-validate.z.string().url()only checks WHATWG URL parser acceptance — it doesn't restrict to https or block private IPs. Layer additional validation for SSRF safety:const SafeUrl = z.string().url().refine((u) => { const url = new URL(u); return url.protocol === "https:" && !/^(10\.|192\.168\.|127\.)/.test(url.hostname); });- Large payload DoS.
z.array(...)with no.max()accepts arrays of any size. Always bound:z.array(Item).max(1000) safeParseerror message leak. ZodError contains paths through the schema — exposes structure to attackers. Usezod-validation-errorto flatten or strip in API responses.
Testing strategies
Test schema directly
import { describe, it, expect } from "vitest";
import { CreateUserBody } from "./schema";
describe("CreateUserBody", () => {
it("accepts valid input", () => {
const r = CreateUserBody.safeParse({ email: "alice@example.com", name: "Alice" });
expect(r.success).toBe(true);
});
it("rejects bad email", () => {
const r = CreateUserBody.safeParse({ email: "not-an-email", name: "Alice" });
expect(r.success).toBe(false);
if (!r.success) {
expect(r.error.issues[0].path).toEqual(["email"]);
}
});
});
Property-based testing with fast-check + zod-fast-check
import * as fc from "fast-check";
import { ZodFastCheck } from "zod-fast-check";
const userArb = ZodFastCheck().inputOf(CreateUserBody);
it("any valid input parses", () => {
fc.assert(fc.property(userArb, (input) => {
expect(CreateUserBody.safeParse(input).success).toBe(true);
}));
});
Configuration patterns
Sharing schemas across packages
In a monorepo, define schemas once and import everywhere. Type-infer in consumer code with z.infer<typeof Schema>. The schema is the type — no risk of drift.
Composition
const Base = z.object({ id: z.string(), createdAt: z.date() });
const User = Base.extend({ email: z.string().email() });
const Post = Base.extend({ title: z.string(), authorId: z.string() });
.extend adds keys; .merge combines two schemas; .omit and .pick slim.
Troubleshooting common errors
passthrough is not a function— v3 → v4 migration. Usez.looseObjectinstead.Type 'unknown' is not assignable to type '...'after upgrading —safeParsereturn-type narrowed in v4. Addif (result.success)before accessingresult.data.Cannot read properties of undefined (reading 'issues')— accessing.error.issueswithout checking.successfirst.ZodError: [{ path: [], message: "Invalid input" }]—.refinewithout apath. Add{ path: ["fieldName"], message: "..." }.- TS error after adding
.transform()— return type changed fromZodEffectstoZodPipelinein v4. Most code is unaffected; libraries that store schemas typed asZodEffects<T>need updates. Cannot find module 'zod/mini'— bundler doesn't honour theexportsmap. Upgrade to Webpack 5+, Vite, esbuild, or Rollup.
Ecosystem integrations
| Tool | What it adds |
|---|---|
zod-validation-error | Pretty-print ZodError for HTTP responses |
zod-to-json-schema | Emit JSON Schema (OpenAPI, MCP tool schemas, JSON-driven UIs) |
@hookform/resolvers | React Hook Form bridge — zodResolver(schema) |
@trpc/server | tRPC accepts Zod schemas directly as .input(schema) |
t3-env / @t3-oss/env-nextjs | Type-safe env parsing wrapper |
zod-fetch / @better-fetch/fetch | Typed fetch with response validation |
zod-prisma-types / prisma-zod-generator | Generate Zod schemas from schema.prisma |
zod-openapi / @asteasolutions/zod-to-openapi | OpenAPI 3 emitters |
zod-fast-check | fast-check arbitraries from Zod schemas |
astro:env | Astro's first-class env validation uses Zod under the hood |
When NOT to use this
- Simple JSON parse of trusted data.
JSON.parse(s) as MyTypeis enough for trusted internal IPC. Don't add Zod for static config you control. - Bundle-size-critical client. Valibot is ~1 KB minimum vs Zod's ~10 KB (or
zod/mini's ~3-4 KB). Pick Valibot for performance-critical web apps if Zod's ecosystem isn't needed. - TS-syntax-like schemas. ArkType lets you write
type({ name: "string" })that compiles to a fast validator. Pick if you prefer that ergonomics. - JSON Schema is the contract. Ajv is the canonical JSON Schema validator. Don't translate JSON Schema → Zod when consumers expect JSON Schema; pair Zod with
zod-to-json-schemaonly when you want both. - Pure type-level work. Zod is runtime + types. For type-only manipulation (e.g.
Path<T>,DeepPartial<T>) use type-fest.
See also
- JavaScript: zod — full API, recipes, integrations
- Concept: JSON — schema-validated inputs at API boundaries
- Concept: API — runtime contract enforcement
- Packages: npm-prisma — pair via
zod-prisma-types