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.
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.
| Helper | Describes | Differs from infer when |
|---|---|---|
z.infer<typeof S> | The parsed output | Never, it is the output |
z.output<typeof S> | The parsed output | Never, same as infer |
z.input<typeof S> | What parse accepts | Coercion, transform, default or pipe is used |
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) |
Credits
- AuthorShree Kumar Sharma
- DepartmentBackend Engineering
- Co-AuthorClaude Design