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.
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
- Think of it as the rulebook the customs officer holds
- 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
- Think of it as the inspection itself: does this bag follow the rules?
- 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
- Think of it as putting one traveller's bag through the scanner
- 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
- Think of it as a label on a box saying what must be inside it
- 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
- Think of it as the moment passengers actually land, rather than the plan drawn up beforehand
- 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
- Think of it as one box on the customs form, such as "name" or "age"
- 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
- Think of it as checking that a passport number has the right pattern of letters and digits
- 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
- Think of it as the translator at the desk who turns "twenty nine" into 29
- 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
- Think of it as a whole arrival form with several labelled boxes
- 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
- Think of it as a list of bags, every one checked against the same rules
- An ordered list of values.
z.array(item)passes only if every item in the list passes the inner schema. - Optional and required
- Think of it as boxes you must fill in versus boxes marked "if applicable"
- 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
- Think of it as an item in the bag that isn't on the customs form
- Extra fields you didn't ask for, like a sneaky
isAdmin: truein a signup form. Plainz.objectquietly removes them,z.strictObjectrejects the whole thing, andz.looseObjectlets them through. - Deriving a schema
- Think of it as photocopying a form and crossing out or adding a few boxes
- 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
- Think of it as an officer who sounds the alarm, compared with one who hands you a result slip
.parse()stops the program with an error when data fails..safeParse()never does; it returns a slip sayingsuccess: truewith the data, orfalsewith the error, so you decide what to do.- ZodError and issues
- Think of it as the written report listing every problem found in the bag
- 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
- Think of it as a senior officer who looks at the whole bag and asks one extra question
- 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
- Think of it as repacking the goods neatly after they pass inspection
- 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
- Think of it as a conveyor belt from one desk to the next
- 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
- Think of it as the officer writing "none" in an empty box instead of sending you back
.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
- Think of it as a short guest list: only the names on it get in
- A fixed list of allowed text values.
z.enum(["admin", "editor", "viewer"])accepts exactly those three and nothing else. - Union type
- Think of it as checking a passport against several countries' rules until one fits
- 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
- Think of it as reading the sign on the bag first, then going straight to the right desk
- 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
- Think of it as the desk printing your boarding pass straight from the rulebook
- 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
- Think of it as what a traveller carries in versus what they walk out with after customs
z.inputdescribes what may arrive andz.outputwhat you get back. They differ whenever coercion, transforms or defaults change the value along the way.- Joi
- Think of it as the veteran officer at the older airport next door
- 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.
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.
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 desk | In Zod | What it is |
|---|---|---|
| Rulebook | z.object({...}) | The schema: a value you build once and reuse |
| Inspection | .parse() / .safeParse() | Runs every rule against unknown input |
| Cleared traveller | result.data | A fresh, typed copy of the input with unknown keys removed |
| Incident report | ZodError.issues | A list of problems, each with a path, code and message |
| Boarding pass | z.infer<typeof S> | The TypeScript type printed from the same rulebook |
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);
$ 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.
Primitives
Build the single fields first: strings, numbers, formats and the coercion that turns form text into real values.
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.
Accepts text. Chain min, max, length, regex, trim, toLowerCase.
See the exampleAccepts a finite number. Add int, positive, min, max, multipleOf.
See the exampleA string in email format. Siblings: z.uuid, z.url, z.jwt, z.ipv4.
See the exampleAn ISO 8601 timestamp string such as 2026-09-29T10:00:00Z.
See the exampleConverts first, then checks. Ideal for query params and env vars.
See the exampleExactly one allowed value, like "sms" or 42.
See the exampleconst 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);
$ 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.
| Rule | On | What it enforces |
|---|---|---|
.min(n) / .max(n) | string, number, array | Length for strings and arrays, value for numbers |
.length(n) | string, array | Exact length |
.regex(re) | string | Must match the pattern |
.int() | number | A safe integer, no decimals |
.positive() / .nonnegative() | number | Greater than 0, or 0 and up |
.optional() | any | Also accepts undefined |
.nullable() / .nullish() | any | Also accepts null, or null and undefined |
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.
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.
A fixed shape. Unknown keys are stripped from the result.
See the exampleSame shape, but any unknown key fails the parse.
See the exampleKeep or drop keys to build a new schema from an existing one.
See the exampleMakes every key optional, the classic PATCH body.
See the exampleAdds or overrides keys, like a base user plus admin fields.
See the exampleA list where every item passes the inner schema.
See the exampleconst 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" }));
$ 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 want | Write | Result for extra keys |
|---|---|---|
| Quietly drop extras | z.object(shape) | Removed from data |
| Fail on extras | z.strictObject(shape) | Issue: unrecognized_keys |
| Keep extras | z.looseObject(shape) | Passed through as is |
| Validate extras | z.object(shape).catchall(z.string()) | Each extra checked by the catchall |
| A map of ids | z.record(z.string(), z.number()) | Any keys, typed values |
Parsing and errors
Choose between throwing and returning, then turn raw issues into messages a form or an API client can actually use.
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.
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().
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);
}
$ 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.
| Helper | Shape you get | Best for |
|---|---|---|
error.issues | Flat 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 schema | Deeply nested objects and arrays |
z.prettifyError(err) | A multi line string | Logs, CLIs, thrown messages |
Refine and transform
Add rules no type can express, reshape values on the way in, and fill blanks with defaults.
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.
A custom yes or no check. Can be async with parseAsync.
See the examplePush several issues yourself when one rule has many outcomes.
See the exampleChanges the value after it passes. The output type follows fn.
See the exampleFeeds the output of one schema into another for a second round.
See the exampleUsed when the input is undefined. The output is never undefined.
See the exampleSwaps any failure for a fallback value instead of an error.
See the exampleconst 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));
$ 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.
Unions and enums
Accept one of several shapes, pick fast with a discriminator key, and lock strings to a fixed list.
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.
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);
$ 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.
| Tool | Use when | Picks the option by |
|---|---|---|
z.enum([...]) | A fixed set of strings | Membership in the list |
z.literal(v) | Exactly one value | Equality |
z.union([A, B]) | Shapes with no shared label | Trying each in order |
z.discriminatedUnion("type", [...]) | Objects that share a label key | Reading the label first |
z.xor([A, B]) | Exactly one option may match | Failing if two match |
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.
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.
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));
$ 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.
Zod vs Joi
Same job, different instincts. See where the defaults disagree before you move a codebase from one to the other.
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.
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 }));
$ 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.
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));
$ 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
| Behaviour | Joi | Zod |
|---|---|---|
| Missing key | Allowed (optional by default) | Issue (required by default) |
| "29" for a number | Converted to 29 (convert: true) | Issue, unless z.coerce.number() |
| Unknown key | Error (allowUnknown: false) | Stripped from data |
| Errors reported | First only (abortEarly: true) | All of them |
| Return shape | { value, error } | { success, data | error } |
| Throwing call | Joi.attempt / validateAsync | .parse / .parseAsync |
| TypeScript types | Written by hand | Inferred with z.infer |
| Runs in the browser | Separate browser build | Yes, plus a tree shakable Zod Mini |
| Schema to JSON Schema | Community packages | Built in: z.toJSONSchema() |
Translating Joi into Zod
| Joi | Zod | Note |
|---|---|---|
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 |
Which one do I need?
Find your situation, take the tool in the middle column and copy the starting line.
| Situation | Reach for | Start with |
|---|---|---|
| New TypeScript API or monorepo | Zod | z.strictObject({ ... }) |
| Validate env vars at boot | Zod | z.object({ PORT: z.coerce.number() }).parse(process.env) |
| Form shared by React and the server | Zod | z.flattenError(result.error) |
| Query string with numbers and booleans | Zod | z.coerce.number(), z.stringbool() |
| Webhook with a type field | Zod | z.discriminatedUnion("type", [...]) |
| Existing hapi or plain JS service on Joi | Stay on Joi | schema.validate(body, { abortEarly: false }) |
| Joi code you want typed | Migrate module by module | Joi.string().required() becomes z.string() |
| OpenAPI or JSON Schema from validators | Zod | z.toJSONSchema(Schema) |