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

bash
# 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.

bash
# 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)).

bash
# 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 — landed zod/mini, top-level functions, dropped chainable .passthrough(), narrower safeParse return type, faster runtime). 3.x is now legacy; both lines remain published and many ecosystem packages still pin to ^3 while 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:

PackagePurpose
zod-validation-errorPretty-print ZodError into one human-readable message — standard for API responses.
zod-to-json-schemaEmit JSON Schema from Zod. Used for OpenAPI generators, MCP tool schemas, JSON-Schema-driven UIs.
zod-fetch / @better-fetch/fetchTyped fetch wrappers that validate response shapes against Zod schemas.
@hookform/resolversReact Hook Form integration — zodResolver(schema).
t3-env / @t3-oss/env-nextjsType-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-generatorAuto-generate Zod schemas from schema.prisma.
zod-openapi / @asteasolutions/zod-to-openapiOpenAPI 3 emitters.
valibot-zod-codec and similar bridgesCross-validator interop.

Alternatives

LibraryTrade-off
valibotModular 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.
arktypeTS-syntax-like schemas (type({ name: "string" })) compiled to fast validators. Closest to a "TS-as-runtime-types" feel. Growing ecosystem; smaller than Zod's.
yupThe older "schema first" validator (pre-Zod). Less type-safe inference, more JS-y. Still common in legacy Formik projects.
joiHapi's validator. Excellent runtime; type inference much weaker than Zod. Stronger choice for plain-JS Node services.
ajvJSON-Schema validator. Pick when your contract is JSON Schema (OpenAPI consumers, cross-language); compose with zod-to-json-schema.
superstructOlder lightweight option. Smaller ecosystem; still serviceable for tiny schemas.

Common gotchas

  1. v4 breaking changes are real. The chainable .passthrough() is gone — use z.looseObject({ ... }) for the same behaviour. .strict() and .strip() similarly moved. Many tutorials and Stack Overflow answers still target v3 syntax that throws on v4.
  2. safeParse return-type narrowing tightened in v4. result.success === true narrows to { success: true; data: T }; false narrows to { success: false; error: ZodError }. Older code that destructured { data, error } unconditionally now sees data typed as T | undefined — TS flags it; fix with the narrowing check.
  3. z.coerce.* runs String() / Number() / Date() blindly. z.coerce.number().parse("abc") returns NaN, then fails the schema with "Expected number, received nan" — confusing because it looks like the coerce should have errored. Avoid coerce for user input; use .transform() with explicit parsing.
  4. .optional() vs .nullable() vs .nullish(). .optional() = T | undefined; .nullable() = T | null; .nullish() = T | null | undefined. JSON inputs from APIs usually deliver null, not undefined — pick .nullable() or .nullish() to match.
  5. refine errors 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 is ZodError: ["Invalid input"] with no indication of which field.
  6. z.infer is structural — extends quietly. Adding a field to a schema silently widens every z.infer<> consumer's type. Plan major schema changes the way you would a public TS interface change.
  7. Bundle size on the browser. A single import { z } from "zod" pulls ~10 KB gzipped even if you only use z.string(). For client-side bundles use zod/mini (v4) or migrate the smallest schemas to valibot.

Real-world recipes

The patterns that come up at every API and form boundary in real Zod code.

Validate an HTTP request body

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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 a z.string() schema.
  • zod/mini (v4) — ~3-4 KB gzipped, ~50% smaller. Same validation surface; methods become standalone functions.
typescript
// 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:

typescript
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:

typescript
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

Changev3v4
.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 modestripstrip (unchanged)
Top-level functionsonly .method() chainsparse(schema, input), safeParse(schema, input) available
zod/mini builddoesn't existnew — ~50% smaller, functional API
.transform() chainingreturns ZodEffectsreturns ZodPipeline — narrower return type
.refine() asyncparseAsync requiredunchanged but type-narrowing improved
z.coerce.*quirky .parse("abc") returns NaNunchanged; still avoid for user input
Brand syntax.brand<"Tag">().brand<"Tag">() (unchanged)
Error formaterror.errorserror.issues (was already preferred in v3 but enforced in v4)

Mechanical fixes

typescript
// v3
const Loose = z.object({ name: z.string() }).passthrough();

// v4
const Loose = z.looseObject({ name: z.string() });
typescript
// 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);
}
typescript
// 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

bash
# 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:

text
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

  1. 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.
  2. 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 with safe-regex or audit manually.
  3. .transform() runs arbitrary code. A transform that calls JSON.parse(s) on attacker-controlled string can throw — wrap or pre-validate.
  4. 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:
    typescript
    const SafeUrl = z.string().url().refine((u) => {
      const url = new URL(u);
      return url.protocol === "https:" && !/^(10\.|192\.168\.|127\.)/.test(url.hostname);
    });
    
  5. Large payload DoS. z.array(...) with no .max() accepts arrays of any size. Always bound:
    typescript
    z.array(Item).max(1000)
    
  6. safeParse error message leak. ZodError contains paths through the schema — exposes structure to attackers. Use zod-validation-error to flatten or strip in API responses.

Testing strategies

Test schema directly

typescript
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

typescript
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

typescript
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. Use z.looseObject instead.
  • Type 'unknown' is not assignable to type '...' after upgrading — safeParse return-type narrowed in v4. Add if (result.success) before accessing result.data.
  • Cannot read properties of undefined (reading 'issues') — accessing .error.issues without checking .success first.
  • ZodError: [{ path: [], message: "Invalid input" }].refine without a path. Add { path: ["fieldName"], message: "..." }.
  • TS error after adding .transform() — return type changed from ZodEffects to ZodPipeline in v4. Most code is unaffected; libraries that store schemas typed as ZodEffects<T> need updates.
  • Cannot find module 'zod/mini' — bundler doesn't honour the exports map. Upgrade to Webpack 5+, Vite, esbuild, or Rollup.

Ecosystem integrations

ToolWhat it adds
zod-validation-errorPretty-print ZodError for HTTP responses
zod-to-json-schemaEmit JSON Schema (OpenAPI, MCP tool schemas, JSON-driven UIs)
@hookform/resolversReact Hook Form bridge — zodResolver(schema)
@trpc/servertRPC accepts Zod schemas directly as .input(schema)
t3-env / @t3-oss/env-nextjsType-safe env parsing wrapper
zod-fetch / @better-fetch/fetchTyped fetch with response validation
zod-prisma-types / prisma-zod-generatorGenerate Zod schemas from schema.prisma
zod-openapi / @asteasolutions/zod-to-openapiOpenAPI 3 emitters
zod-fast-checkfast-check arbitraries from Zod schemas
astro:envAstro'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 MyType is 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-schema only 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