Zod Cheatsheet 0/8

Backend engineeringRuntime validationZod 4 and Joi 18

Zod
Cheatsheet

A schema is a customs desk for your data: everything from outside gets inspected before it enters your code. Zod checks at runtime and prints the TypeScript type from the same rulebook. Here is every rule you reach for daily, runnable in the page, then how it all compares with Joi.

TypeScriptJavaScript
00

Key terms in plain words

New to checking data in code? Start here. Every word below shows up later on this page, and each one comes with a comparison from a customs desk at an airport. Underlined words in the modules link back to these cards.

25 terms in 6 groups. Hover an underlined word anywhere on the page for a quick reminder, or click it to jump here.

What Zod is for

Zod works like a customs desk. Data from outside arrives, an officer inspects it against a rulebook, and it either walks in cleaned up or gets turned back with a written report.

Schema
A description of what good data looks like: which fields it has and what kind of value goes in each. In Zod you write it once in code, and it does the checking for you.
For example z.object({ name: z.string() }) is a schema for something with a text name.
Validation
Checking that data is the right shape before your app trusts it. Anything that comes from outside, such as a form, a request or a setting, can be wrong or even hostile.
Parsing
Running unknown data through a schema. If it passes you get back clean data, possibly tidied up; if it fails you get a report of what was wrong.
Type
In TypeScript, a type tells your code editor what shape a value has, so it can warn you about mistakes as you write. Types only exist while you write code; they vanish when the program actually runs.
Runtime
The time when your program is really running and handling real data. Type checks happen earlier, while code is compiled (turned into the version that runs), so only a runtime check like Zod's can catch bad data that arrives later.

Single fields

The individual boxes on the customs form, and how each one checks a single value.

Primitive
The simplest kind of value: a piece of text, a number, true or false, a date. Bigger schemas are built out of these.
Format check
A ready-made rule for text that must look a certain way, such as an email address, a web address or a date. In Zod 4 these are short functions like z.email().
Coercion
Converting a value to the right kind before checking it. Web forms and addresses always deliver text, so z.coerce.number() turns "29" into the number 29 first.

Whole shapes

Putting fields together into a full form, and deciding what happens to anything extra in the bag.

Object
A group of named values kept together, such as a user with a name, email and age. z.object({...}) checks every named box at once.
Array
An ordered list of values. z.array(item) passes only if every item in the list passes the inner schema.
Optional and required
In Zod every key on an object must be there unless you add .optional(). Joi works the other way round, so this matters when moving between them.
Unknown keys
Extra fields you didn't ask for, like a sneaky isAdmin: true in a signup form. Plain z.object quietly removes them, z.strictObject rejects the whole thing, and z.looseObject lets them through.
Deriving a schema
Building a new schema from an existing one with .pick(), .omit(), .extend() or .partial(), instead of copying it by hand. The original stays as it was.

When something fails

How the officer turns someone back, and how to read the report they hand you.

parse vs safeParse
.parse() stops the program with an error when data fails. .safeParse() never does; it returns a slip saying success: true with the data, or false with the error, so you decide what to do.
ZodError and issues
When a parse fails, Zod gives back a ZodError holding a list of issues. Each issue says where the problem is (its path, such as ["address", "pin"]), a short code and a message a person can read.

Custom rules and reshaping

Checks the scanner can't do on its own, and repacking the goods on their way through.

Refine
A custom yes or no rule you write yourself, for checks no type can describe, such as "the end date must be after the start date".
Transform
Changing a value once it has passed, such as splitting a comma list into a list or lowercasing an email. What comes out can be a different kind of value from what went in.
Pipe
Feeds the result of one schema into another for a second round of checks, for example check the text, convert it to a number, then check the number.
Default and catch
.default(value) fills in a field that is missing. .catch(value) goes further and swaps any failure for a fallback, so use it sparingly.

Choices and types for free

Accepting one of several shapes, and letting the rulebook write your TypeScript types too.

Enum
A fixed list of allowed text values. z.enum(["admin", "editor", "viewer"]) accepts exactly those three and nothing else.
Union type
A value that may be any one of several shapes. Zod tries each option in turn and passes if any of them matches.
Discriminated union
A union where every option carries one label field, such as type. Zod reads that label and checks only the matching shape, which is faster and gives clearer errors.
Type inference
Letting a tool work out a type for you. z.infer<typeof Order> reads the schema and produces the matching TypeScript type, so the check and the type can never drift apart.
Input and output types
z.input describes what may arrive and z.output what you get back. They differ whenever coercion, transforms or defaults change the value along the way.
Joi
An older validation library for Node.js. It does the same job with opposite habits: it converts values without being asked, rejects unknown keys and treats every key as optional until marked required.
01

The mental model

Think of a schema as a customs desk. Data from outside arrives, gets inspected, and either walks in cleaned up or gets turned back with a written report.

Analogy firstSchemaParseMust know

Anything that crosses into your app from the outside world is a traveller you have never met: a request body, a query string, a webhook, an environment variable, a row from a partner API. TypeScript cannot vouch for them, because its types vanish when the code compiles. A schema is the rulebook the customs officer holds, and parsing is the inspection that happens at runtime, every single time.

Zod adds one twist that makes it feel different from older validators. The same rulebook also prints the TypeScript type, so the check your server runs and the type your editor trusts can never drift apart. You write z.object(...) once and get both.

Picture it: one bag goes through inspection

name: "Asha"age: "29"User.safeParse(bag)→!The age is a string, not a number, so the bag is turned back with one issue instead of slipping into your code.
At the deskIn ZodWhat it is
Rulebookz.object({...})The schema: a value you build once and reuse
Inspection.parse() / .safeParse()Runs every rule against unknown input
Cleared travellerresult.dataA fresh, typed copy of the input with unknown keys removed
Incident reportZodError.issuesA list of problems, each with a path, code and message
Boarding passz.infer<typeof S>The TypeScript type printed from the same rulebook
model.jsJS
const User = z.object({ name: z.string(), age: z.number() });

const ok = User.safeParse({ name: "Asha", age: 29 });
console.log(ok.success, ok.data);

const bad = User.safeParse({ name: "Asha", age: "29" });
console.log(bad.success, bad.error.issues[0].message);
TerminalOutput
$ node model.js
true { name: 'Asha', age: 29 }
false Invalid input: expected number, received string

Why it matters: the input is checked at runtime, where TypeScript cannot see. A string where a number belongs is caught at the door, not three functions later as NaN.

02

Primitives

Build the single fields first: strings, numbers, formats and the coercion that turns form text into real values.

StringsFormatsCoercionMust know

Primitives are the individual boxes on the customs form. Each one checks one kind of value, and you chain rules onto it like stamps: z.string().min(2).max(50). Every method returns a new schema, so the original is never changed and you can share base schemas safely.

In Zod 4 the common formats are top level functions: z.email(), z.uuid(), z.url(), z.iso.datetime(). The older z.string().email() still works but is deprecated. Coercion is the translator at the desk: z.coerce.number() runs Number(input) first, which is exactly what you want for query strings and form fields that always arrive as text.

z.string()

Accepts text. Chain min, max, length, regex, trim, toLowerCase.

See the example
TextChainable
z.number()

Accepts a finite number. Add int, positive, min, max, multipleOf.

See the example
NumberNo NaN
z.email()

A string in email format. Siblings: z.uuid, z.url, z.jwt, z.ipv4.

See the example
FormatZod 4
z.iso.datetime()

An ISO 8601 timestamp string such as 2026-09-29T10:00:00Z.

See the example
DatesString
z.coerce.number()

Converts first, then checks. Ideal for query params and env vars.

See the example
ConvertsInput unknown
z.literal(value)

Exactly one allowed value, like "sms" or 42.

See the example
ExactUnions
primitives.jsJS
const Email = z.email();
console.log(Email.safeParse("shree@example.com").success);
console.log(Email.safeParse("not-an-email").success);

const Port = z.coerce.number().int().min(1).max(65535);
console.log(Port.parse("8080"), typeof Port.parse("8080"));

const Name = z.string().trim().min(2).toLowerCase();
console.log(Name.parse("  RAVI  "));

const When = z.iso.datetime();
console.log(When.safeParse("2026-09-29T10:00:00Z").success);
TerminalOutput
$ node primitives.js
true
false
8080 number
ravi
true

Why it matters: .trim() and .toLowerCase() change the value as it passes, and z.coerce turns the text "8080" into the number 8080. Inspection can clean, not only reject.

RuleOnWhat it enforces
.min(n) / .max(n)string, number, arrayLength for strings and arrays, value for numbers
.length(n)string, arrayExact length
.regex(re)stringMust match the pattern
.int()numberA safe integer, no decimals
.positive() / .nonnegative()numberGreater than 0, or 0 and up
.optional()anyAlso accepts undefined
.nullable() / .nullish()anyAlso accepts null, or null and undefined
03

Objects and arrays

Shape whole request bodies, decide what happens to keys you did not ask for, and derive patch and admin variants without copy and paste.

ObjectsUnknown keyspick omit extendMust know

An object schema is a form with fixed boxes. Every key is required by default; add .optional() to make one skippable. The interesting question is what the officer does with a box that is not on the form, like a sneaky isAdmin: true in a signup body.

Plain z.object quietly strips unknown keys, so they never reach your database. z.strictObject rejects the whole bag with an unrecognized_keys issue, and z.looseObject lets extras through untouched. Pick strict for public APIs where a typo should fail loudly, and loose only when you forward data you do not own.

z.object({ ... })

A fixed shape. Unknown keys are stripped from the result.

See the example
Strips extrasRequired keys
z.strictObject({ ... })

Same shape, but any unknown key fails the parse.

See the example
Rejects extrasPublic APIs
.pick / .omit({ key: true })

Keep or drop keys to build a new schema from an existing one.

See the example
New schemaNo copy paste
.partial()

Makes every key optional, the classic PATCH body.

See the example
All optionalPATCH
.extend({ ... })

Adds or overrides keys, like a base user plus admin fields.

See the example
Adds keysComposes
z.array(schema)

A list where every item passes the inner schema.

See the example
Lists.min .max
objects.jsJS
const User = z.object({ id: z.number(), name: z.string(), role: z.string() });
const body = { id: 1, name: "Ravi", role: "admin", isAdmin: true };

console.log(User.parse(body));
console.log(z.strictObject(User.shape).safeParse(body).error.issues[0].code);
console.log(z.looseObject(User.shape).parse(body));

const Patch = User.omit({ id: true }).partial();
console.log(Patch.parse({ name: "Ravi K" }));

const Admin = User.extend({ scopes: z.array(z.string()).default([]) });
console.log(Admin.parse({ id: 2, name: "Meera", role: "admin" }));
TerminalOutput
$ node objects.js
{ id: 1, name: 'Ravi', role: 'admin' }
unrecognized_keys
{ id: 1, name: 'Ravi', role: 'admin', isAdmin: true }
{ name: 'Ravi K' }
{ id: 2, name: 'Meera', role: 'admin', scopes: [] }

Why it matters: the default strip mode is a quiet safety net: isAdmin simply disappears from the parsed data. Deriving Patch and Admin from one User keeps every variant in step when the base changes.

You wantWriteResult for extra keys
Quietly drop extrasz.object(shape)Removed from data
Fail on extrasz.strictObject(shape)Issue: unrecognized_keys
Keep extrasz.looseObject(shape)Passed through as is
Validate extrasz.object(shape).catchall(z.string())Each extra checked by the catchall
A map of idsz.record(z.string(), z.number())Any keys, typed values
04

Parsing and errors

Choose between throwing and returning, then turn raw issues into messages a form or an API client can actually use.

safeParseZodErrorFormattingMust know

There are two ways to run the inspection. .parse() is the strict officer: pass and you get the data, fail and it throws a ZodError. .safeParse() never throws; it hands you a result slip with success set to true or false. Reach for safeParse in request handlers and forms, and parse at startup for config, where crashing early is the right move.

parseSchema.parse(input)Returns data or throws. Good for env config and scripts.
safeParseSchema.safeParse(input)Returns { success, data } or { success, error }. Good for APIs and forms.

The error holds an issues array. Each issue carries a path such as ["address", "pin"], a machine code and a human message. Zod 4 adds helpers that reshape that list for you. Anything async inside the schema, like a refine that queries the database, needs .parseAsync() or .safeParseAsync().

errors.jsJS
const Signup = z.object({ email: z.email(), password: z.string().min(8) });

const r = Signup.safeParse({ email: "x", password: "123" });
if (!r.success) {
  const { fieldErrors } = z.flattenError(r.error);
  console.log(fieldErrors.email);
  console.log(fieldErrors.password);
  console.log(z.prettifyError(r.error));
}

try {
  Signup.parse({});
} catch (e) {
  console.log(e instanceof z.ZodError, e.issues.length);
}
TerminalOutput
$ node errors.js
[ 'Invalid email address' ]
[ 'Too small: expected string to have >=8 characters' ]
✖ Invalid email address
  → at email
✖ Too small: expected string to have >=8 characters
  → at password
true 2

Why it matters: flattenError gives a form one list per field, and prettifyError gives logs a readable block. Unlike Joi out of the box, Zod reports every failing field in one pass, so the user fixes the whole form at once.

HelperShape you getBest for
error.issuesFlat array of { path, code, message }Custom mapping, API error bodies
z.flattenError(err){ formErrors, fieldErrors }Flat forms, one error list per field
z.treeifyError(err)Nested tree matching the schemaDeeply nested objects and arrays
z.prettifyError(err)A multi line stringLogs, CLIs, thrown messages
05

Refine and transform

Add rules no type can express, reshape values on the way in, and fill blanks with defaults.

refinetransformpipedefault

The scanner checks shapes; refine is the senior officer who looks at the whole bag and asks a question the scanner cannot, such as "is the end date after the start date?". Return true to pass, and give it an error message and a path so the issue lands on the right field.

Transform repackages the goods after they pass: split a comma list into an array, or trim and lowercase an email. Pipe is a conveyor belt from one desk to the next, so z.string().pipe(z.coerce.number().min(18)) checks the raw text, converts it, then checks the number. Default fills an empty box with a sensible value when the field is missing.

.refine(fn, { error, path })

A custom yes or no check. Can be async with parseAsync.

See the example
Adds a ruleKeeps type
.superRefine((val, ctx) => ...)

Push several issues yourself when one rule has many outcomes.

See the example
Many issuesAdvanced
.transform(fn)

Changes the value after it passes. The output type follows fn.

See the example
Changes typeAfter checks
.pipe(schema)

Feeds the output of one schema into another for a second round.

See the example
ChainsInput to output
.default(value)

Used when the input is undefined. The output is never undefined.

See the example
Fills blanksShort circuits
.catch(value)

Swaps any failure for a fallback value instead of an error.

See the example
Never failsUse sparingly
refine.jsJS
const Range = z.object({ from: z.number(), to: z.number() })
  .refine((r) => r.to > r.from, { error: "to must be after from", path: ["to"] });
const bad = Range.safeParse({ from: 5, to: 2 });
console.log(bad.error.issues[0].path, bad.error.issues[0].message);

const Tags = z.string().transform((s) => s.split(",").map((t) => t.trim()));
console.log(Tags.parse("node, zod ,  joi"));

const Age = z.string().pipe(z.coerce.number().int().min(18));
console.log(Age.safeParse("17").success, Age.parse("21"));

const Page = z.number().int().positive().default(1);
console.log(Page.parse(undefined));
TerminalOutput
$ node refine.js
[ 'to' ] to must be after from
[ 'node', 'zod', 'joi' ]
false 21
1

Why it matters: business rules live next to the shape they guard. The path option pins the date error to to, so a form can show it under the right input.

06

Unions and enums

Accept one of several shapes, pick fast with a discriminator key, and lock strings to a fixed list.

z.enumdiscriminatedUnionz.union

An enum is a short guest list: only the names on it get in. z.enum(["admin", "editor", "viewer"]) gives you the check, the list at .options and a string union type for free.

A union tries each option in turn, like an officer who checks your passport against several countries. A discriminated union is quicker and gives clearer errors: it reads one label key first, such as type, and goes straight to the matching form. Use it for events, webhooks and notification payloads, which nearly always carry that kind of label.

unions.jsJS
const Role = z.enum(["admin", "editor", "viewer"]);
console.log(Role.options, Role.safeParse("owner").success);

const Notify = z.discriminatedUnion("type", [
  z.object({ type: z.literal("email"), to: z.email() }),
  z.object({ type: z.literal("sms"), phone: z.string().regex(/^\+91\d{10}$/) }),
]);
console.log(Notify.parse({ type: "sms", phone: "+919876543210" }));
console.log(Notify.safeParse({ type: "push" }).error.issues[0].code);

const Id = z.union([z.uuid(), z.number().int()]);
console.log(Id.safeParse(42).success, Id.safeParse("42").success);
TerminalOutput
$ node unions.js
[ 'admin', 'editor', 'viewer' ] false
{ type: 'sms', phone: '+919876543210' }
invalid_union
true false

Why it matters: with a type key the union reads the label and checks only the matching option. The string "42" fails the id union because neither a uuid nor an integer accepts it, and Zod does not guess.

ToolUse whenPicks the option by
z.enum([...])A fixed set of stringsMembership in the list
z.literal(v)Exactly one valueEquality
z.union([A, B])Shapes with no shared labelTrying each in order
z.discriminatedUnion("type", [...])Objects that share a label keyReading the label first
z.xor([A, B])Exactly one option may matchFailing if two match
07

Types from schemas

Print the TypeScript type from the schema, and know when the input type and the output type are not the same thing.

z.inferz.inputz.outputMust know

This is the boarding pass. type Order = z.infer<typeof Order> reads the rulebook and writes the type, so there is no separate interface to forget to update. It is common to give the schema and the type the same name; TypeScript keeps values and types in separate spaces.

A schema has two sides. z.input describes what may arrive and z.output (the same as z.infer) describes what you get back. They differ whenever coercion, transforms or defaults are involved: qty below can arrive as anything, since coercion accepts unknown input, and always leaves as a number.

order.tsTS
const Order = z.object({
  id: z.string(),
  qty: z.coerce.number().int().positive(),
  note: z.string().optional(),
});

type OrderIn = z.input<typeof Order>;  // { id: string; qty: unknown; note?: string }
type Order = z.infer<typeof Order>;    // { id: string; qty: number; note?: string }

function total(o: Order, price: number): number {
  return o.qty * price;
}

const o = Order.parse({ id: "A1", qty: "3" });
console.log(o, total(o, 250));
TerminalOutput
$ npx tsx order.ts
{ id: 'A1', qty: 3 } 750

Why it matters: total() is typed from the schema, so if someone renames qty in the schema the compiler flags every caller. Run it and the TypeScript compiler loads in your browser, strips the types and runs the result.

HelperDescribesDiffers from infer when
z.infer<typeof S>The parsed outputNever, it is the output
z.output<typeof S>The parsed outputNever, same as infer
z.input<typeof S>What parse acceptsCoercion, transform, default or pipe is used
08

Zod vs Joi

Same job, different instincts. See where the defaults disagree before you move a codebase from one to the other.

Joi 18Zod 4DefaultsMigration

Joi is the veteran officer from the hapi world, trained on Node servers long before TypeScript was common. It is helpful by nature: it converts "29" into 29 without being asked, and it refuses strangers, so an unknown key fails. Zod is the newer, TypeScript native officer. It never converts unless you say coerce, it quietly drops strangers, and it prints your types as a side effect.

Neither is wrong; they just start from different defaults. Joi keys are optional until you add .required(), while Zod keys are required until you add .optional(). Joi stops at the first error unless you pass abortEarly: false; Zod always collects every issue. Joi ships TypeScript definitions and lets you hint a type with Joi.object<User>(), but that type is a promise you make by hand, not something Joi works out.

defaults.jsJS
const joiUser = Joi.object({
  name: Joi.string().min(2).required(),
  age: Joi.number().integer().min(18),
});
console.log(joiUser.validate({ name: "Asha", age: "29" }).value);
console.log(joiUser.validate({ name: "Asha", isAdmin: true }).error.message);

const zodUser = z.object({
  name: z.string().min(2),
  age: z.number().int().min(18).optional(),
});
console.log(zodUser.safeParse({ name: "Asha", age: "29" }).success);
console.log(zodUser.parse({ name: "Asha", isAdmin: true }));
TerminalOutput
$ node defaults.js
{ name: 'Asha', age: 29 }
"isAdmin" is not allowed
false
{ name: 'Asha' }

Why it matters: the same body gives opposite answers. Joi turns the string into a number and rejects the extra key; Zod rejects the string and silently drops the extra key. Moving between them without knowing this changes behaviour in production.

signup.jsJS
const input = { email: "x", password: "123", confirm: "124" };

const J = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  confirm: Joi.valid(Joi.ref("password")).required(),
});
const jr = J.validate(input, { abortEarly: false });
jr.error.details.forEach((d) => console.log("joi", d.path[0], "→", d.message));

const Z = z.object({ email: z.email(), password: z.string().min(8), confirm: z.string() })
  .refine((v) => v.confirm === v.password, { error: "Passwords do not match", path: ["confirm"] });
const zr = Z.safeParse(input);
zr.error.issues.forEach((i) => console.log("zod", i.path[0], "→", i.message));
TerminalOutput
$ node signup.js
joi email → "email" must be a valid email
joi password → "password" length must be at least 8 characters long
joi confirm → "confirm" must be [ref:password]
zod email → Invalid email address
zod password → Too small: expected string to have >=8 characters
zod confirm → Passwords do not match

Why it matters: both libraries catch all three problems once Joi is told abortEarly: false. Joi needs Joi.ref and a custom message for the password match, while Zod expresses it as a refine with its own message and path.

Defaults side by side

BehaviourJoiZod
Missing keyAllowed (optional by default)Issue (required by default)
"29" for a numberConverted to 29 (convert: true)Issue, unless z.coerce.number()
Unknown keyError (allowUnknown: false)Stripped from data
Errors reportedFirst only (abortEarly: true)All of them
Return shape{ value, error }{ success, data | error }
Throwing callJoi.attempt / validateAsync.parse / .parseAsync
TypeScript typesWritten by handInferred with z.infer
Runs in the browserSeparate browser buildYes, plus a tree shakable Zod Mini
Schema to JSON SchemaCommunity packagesBuilt in: z.toJSONSchema()

Translating Joi into Zod

JoiZodNote
Joi.string().required()z.string()Required is the Zod default
Joi.string()z.string().optional()Optional is the Joi default
Joi.number()z.coerce.number()Only if you relied on Joi converting
Joi.string().email()z.email()Top level format in Zod 4
Joi.string().valid("a", "b")z.enum(["a", "b"])Also gives a union type
Joi.alternatives().try(A, B)z.union([A, B])Use discriminatedUnion when a label exists
Joi.object().unknown(true)z.looseObject(shape)Keep extra keys
{ stripUnknown: true }z.object(shape)Stripping is the Zod default
Joi.valid(Joi.ref("password")).refine(fn, { path })Cross field rules move to refine
.custom(fn).refine(fn) / .transform(fn)Split checking from reshaping
.default(v).default(v)Same idea, same name
.messages({ ... }){ error: "..." }Per rule message
Go

Which one do I need?

Find your situation, take the tool in the middle column and copy the starting line.

SituationReach forStart with
New TypeScript API or monorepoZodz.strictObject({ ... })
Validate env vars at bootZodz.object({ PORT: z.coerce.number() }).parse(process.env)
Form shared by React and the serverZodz.flattenError(result.error)
Query string with numbers and booleansZodz.coerce.number(), z.stringbool()
Webhook with a type fieldZodz.discriminatedUnion("type", [...])
Existing hapi or plain JS service on JoiStay on Joischema.validate(body, { abortEarly: false })
Joi code you want typedMigrate module by moduleJoi.string().required() becomes z.string()
OpenAPI or JSON Schema from validatorsZodz.toJSONSchema(Schema)