Back to All Cheatsheet Libraries cheatsheets

Zod

TypeScript-first schema validation: install, define schemas, parse vs safeParse, error formatting, and what changed in v4.

One schema, two jobs: runtime validation and a TypeScript type

Zod is a TypeScript-first validation library. You define a schema once — for anything from a single string to a deeply nested object — and use it both to validate untrusted data at runtime (form input, API responses, environment variables, LLM output) and to derive the matching TypeScript type with z.infer, so the type and the check can never drift apart. It pairs naturally with npm & Node.js projects and frameworks like Next.js (Server Actions, route handlers, env vars).

Requirements: TypeScript v5.5 or later (older versions may work but aren't officially supported), and "strict": true in tsconfig.json — the docs call this "a best practice for all TypeScript projects," and Zod's own type inference depends on it. Zero runtime dependencies, works in Node.js and every modern browser, and the core bundle is about 2kb gzipped.

1

Install

Zod v4 (currently 4.6.x) is what npm install zod gives you today — there's no separate "v4" package to opt into.

npm install zod pnpm add zod yarn add zod bun add zod deno add npm:zod # also published on JSR as @zod/zod
2

Import

The docs use the namespace import throughout v4 — it's also what makes the tree-shakable Zod Mini variant possible (see the v4 tab).

import * as z from "zod"; // { z } from "zod" still works, but the namespace import // is what the docs use everywhere as of v4
3

Define a schema, infer the type

The type is derived, never hand-written — rename or add a field on the schema and the type updates itself.

const Player = z.object({ username: z.string(), xp: z.number(), }); // static type, derived from the schema — never written by hand type Player = z.infer<typeof Player>;
4

Validate: .parse() vs .safeParse()

.parse() is for data you trust enough to want a hard failure on; .safeParse() is for anything you need to handle gracefully — most form and API input.

// .parse() throws a ZodError on invalid input Player.parse({ username: "billie", xp: 100 }); // ✅ returns the data Player.parse({ username: 42, xp: "100" }); // ❌ throws // .safeParse() never throws — returns a discriminated union instead const result = Player.safeParse({ username: 42, xp: "100" }); if (!result.success) { console.log(result.error); // ZodError } else { console.log(result.data); // validated, typed data } // async schemas (.transform() with a Promise, etc.) use the *Async variants await Player.parseAsync(input); await Player.safeParseAsync(input);

Schema type reference

Every primitive, string format, and composite type Zod ships, plus the object-schema methods you'll reach for constantly. String-format validators (email, URL, UUID, ...) became top-level functions in v4 — the old chained-method style (z.string().email()) still parses but doesn't tree-shake as well.

Showing 0 types
Type Syntax Notes
Stringz.string()UTF-16 string of any length
Numberz.number()finite numbers only — excludes NaN and Infinity
BigIntz.bigint()
Booleanz.boolean()
Datez.date()must be a real Date instance, not a date string
Symbolz.symbol()
Undefinedz.undefined()
Nullz.null()
Voidz.void()alias for z.undefined() — function return values
Anyz.any()disables type checking entirely
Unknownz.unknown()safer than any — narrow it before use
Neverz.never()rejects every value
Emailz.email()top-level in v4, replacing z.string().email()
URLz.url()WHATWG URL parser
UUIDz.uuid() / z.uuidv4() / z.uuidv7()z.uuid({ version: "v4" }) for one specific version
ISO datez.iso.date()YYYY-MM-DD
ISO datetimez.iso.datetime()
IPv4 / IPv6z.ipv4() / z.ipv6()
JWTz.jwt()
Base64z.base64() / z.base64url()
Hexz.hex()
Hashz.hash("sha256")md5, sha1, sha256, sha384, or sha512
Objectz.object({ ... })
Strict objectz.strictObject({ ... })unknown keys fail validation
Loose objectz.looseObject({ ... })unknown keys pass through untouched
Arrayz.array(schema)
Tuplez.tuple([a, b])fixed length, a type per position
Unionz.union([a, b])logical OR — tries each option in order
Discriminated unionz.discriminatedUnion("key", [a, b])faster and clearer errors than a plain union
Recordz.record(keySchema, valSchema)
Map / Setz.map(k, v) / z.set(schema)
Enumz.enum(["a", "b"])also accepts a TypeScript enum
Literalz.literal("value")z.literal([a, b]) for multiple literals
Intersectionz.intersection(a, b)logical AND
Extend.extend({ field: schema })add fields or override existing ones
Pick / Omit.pick({ field: true }) / .omit({ field: true })
Partial.partial()every field becomes optional
Required.required()every field becomes required
Strict.strict()reject unknown keys — same as z.strictObject
Passthrough.passthrough()keep unknown keys — same as z.looseObject
Keyof.keyof()the object's own keys, as a ZodEnum

Constraints, coercion & custom logic

Built-in constraint methods cover most cases; .refine(), .superRefine(), .transform(), and .pipe() cover the rest.

Showing 0 constraints
Applies to Method Meaning
String.min(n) / .max(n) / .length(n)length bounds, in Unicode code points
String.nonempty()alias for .min(1)
String.regex(pattern)must match a regular expression
String.startsWith() / .endsWith() / .includes()substring checks
String.trim() / .toLowerCase() / .toUpperCase()normalise the value before validating
Number.gt(n) / .gte(n)greater than / or equal — .min() is an alias for .gte()
Number.lt(n) / .lte(n)less than / or equal — .max() is an alias for .lte()
Number.positive() / .negative()shorthand for .gt(0) / .lt(0)
Number.nonnegative() / .nonpositive()shorthand for .gte(0) / .lte(0)
Number.multipleOf(n)must be evenly divisible by n — alias .step()
Numberz.int() / z.int32()integer, optionally restricted to the int32 range
Array / Set.min(n) / .max(n) / .length(n)item-count bounds
Array.nonempty()require at least one item
Any schema.optional() / .nullable() / .nullish()allow undefined / null / both
Any schema.default(value)fill in when the input is undefined
Any schema.catch(value)fall back to a value instead of failing
Coercionz.coerce.string()calls String(input) before validating
Coercionz.coerce.number()calls Number(input) before validating
Coercionz.coerce.boolean()calls Boolean(input) before validating

When a built-in constraint isn't enough

Four escape hatches, roughly in order of how often you'll reach for them.

1

.refine() — one custom rule

Runs after the built-in checks pass. Return a boolean; fail with the error option.

const Password = z.string().refine( (val) => /[A-Z]/.test(val), { error: "Password must contain an uppercase letter" } );
2

.transform() — change the output

Validation and transformation in one step; the inferred type reflects the transformed value, not the input.

const Trimmed = z.string().transform((val) => val.trim()); // .transform() changes the *output* type — z.infer reflects it const NumberFromString = z.string().transform((val) => Number(val));
3

.pipe() — chain two schemas

Validate as one type, then feed the result into a second schema. Clearer errors than a single do-everything coercion.

// validate as a string first, then coerce — clearer errors than // z.coerce.number() alone when the input is genuinely malformed const PortFromString = z.string().pipe(z.coerce.number().int().positive());
4

.superRefine() — cross-field / multi-issue

Full control over the issue list via ctx.addIssue() — the only way to validate one field against another.

const Signup = z .object({ password: z.string(), confirm: z.string() }) .superRefine((data, ctx) => { if (data.password !== data.confirm) { ctx.addIssue({ code: "custom", message: "Passwords must match", path: ["confirm"], }); } });

Reading and formatting errors

A failed safeParse() gives you a ZodError with a flat .issues array — three built-in functions reshape it for different audiences.

1

The raw issues array

Every other formatter below is built from this. Each issue carries a code, a path to the offending field, a message, and check-specific detail (e.g. expected).

const result = Player.safeParse({ username: 42, xp: "100" }); if (!result.success) { result.error.issues; // [{ code: "invalid_type", path: ["username"], message: "...", expected: "string" }, ...] }
2

z.prettifyError() — for a log or console

A human-readable multi-line string. Good for CLI tools and server logs, not for a UI.

z.prettifyError(result.error); // ✖ Invalid input: expected string, received number // → at username
3

z.treeifyError() — nested, mirrors the schema

The current recommended way to walk errors for a nested form. Replaces the deprecated z.formatError().

const tree = z.treeifyError(result.error); // { // errors: [], // properties: { // username: { errors: ["Invalid input: expected string, received number"] } // } // } tree.properties?.username?.errors; // ["Invalid input: ..."]
4

z.flattenError() — flat form errors

For a single-level schema (most login/signup forms): one array for whole-form errors, one object of per-field arrays.

const flat = z.flattenError(result.error); // { formErrors: [], fieldErrors: { username: ["Invalid input: ..."] } }
Custom error messages — one unified error param
  • v4 replaced four separate options (message, invalid_type_error, required_error, errorMap) with a single error param that accepts a string or a function — a string or function passed directly is shorthand for it.
  • Precedence, highest to lowest: a check-level error (.min(5, "Too short!")) → a schema-level error (z.string("Invalid name")) → a per-parse error passed to .parse(data, { error }) → the global error map (z.config()) → locale defaults.
z.string("Not a string!"); // schema-level z.string().min(5, "Too short!"); // check-level z.string({ error: "Bad!" }); // object form // context-aware, via a function z.string({ error: (issue) => issue.input === undefined ? "Field is required." : "Invalid input.", });
A global default, for one app-wide tone
  • Set once, applies to every schema that doesn't override it locally.
z.config({ customError: (issue) => { if (issue.code === "invalid_type") { return `invalid type, expected ${issue.expected}`; } return "globally modified error"; }, });

What changed in v4, and what to reach for next

v4 is a from-scratch rewrite of Zod's internals, not an incremental release — the numbers below are from Zod's own benchmarks against v3.

v4 is dramatically faster and smaller

String parsing is 14.71x faster, array parsing 7.43x, object parsing 6.5x, versus v3. The core bundle shrank about 57% (2.3x smaller), and chaining methods like .extend()/.omit() produces roughly 100x fewer tsc type instantiations — the thing that made large v3 schemas slow down your editor.

Zod Mini, for bundle-sensitive code

Same validation logic, a functional API instead of method chaining — trades some discoverability for a smaller tree-shaken bundle. About 64% smaller for a simple schema (2.12kb vs 5.91kb gzipped), widening further on complex object schemas.

import * as z from "zod/mini"; // method chaining becomes function composition z.string().optional().nullable(); // regular zod z.nullable(z.optional(z.string())); // zod/mini // constraint methods become a single .check() call z.string().min(5).max(10).trim(); // regular zod z.string().check(z.minLength(5), z.maxLength(10), z.trim()); // zod/mini
4.5 cut idle-schema memory up to 9.8x

Method memoization defers allocating bound methods until they're actually called. A bare z.string() went from 7.5kb to 784 bytes of retained heap — worth knowing if you build a lot of schemas you don't always exercise.

String formats moved to the top level

Prefer the top-level functions — they tree-shake; the old chained-method spelling still works but won't be trimmed from your bundle if unused elsewhere.

// v4: top-level, tree-shakable functions z.email(); z.url(); z.uuid(); // v3-style chained methods still parse but don't tree-shake as well z.string().email();
Build schemas once, not per-call

Memoization made chaining cheap at definition time, but a schema built inside a hot request handler or render function still redoes that work on every call. Define once at module scope, reuse everywhere.

// ❌ rebuilt on every call — throws away v4's method memoization function validate(input: unknown) { return z.object({ id: z.string(), qty: z.number() }).parse(input); } // ✅ built once, at module scope const OrderLine = z.object({ id: z.string(), qty: z.number() }); function validate(input: unknown) { return OrderLine.parse(input); }
A schema you already have plugs into most of the stack

API: tRPC, nestjs-zod, Express Zod API. Forms: Superforms, conform, zod-validation-error. Zod → other formats: zod-openapi, prisma-zod-generator. Other formats → Zod: orval, Hey API. Full list on the Ecosystem page (Docs tab).

Official docs & further reading

The primary source is zod.dev, written and maintained by Zod's author. Every claim on this page can be checked against one of these — and Zod moves fast, so when this page and the docs disagree, the docs win.