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
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)

Credits

  • AuthorShree Kumar Sharma
  • DepartmentBackend Engineering
  • Co-AuthorClaude Design