Validation with Zod
Zod is a TypeScript-first schema library that has become the default validator for new Node projects. Its killer feature: a schema is also a TypeScript type. You define the shape once, and Zod gives you both runtime validation and a static type inferred from the same source — no drift between what you check and what your types say. This page covers schemas, parsing, type inference, transforms, and Express integration.
Setup and a first schema
Bash
npm install zod
JS
import { z } from 'zod'
const UserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['user', 'admin']).default('user'),
tags: z.array(z.string()).max(10).default([]),
})Fields are REQUIRED by default — opt out with `.optional()`
Note the inverse of [Joi](/nodejs/joi): in Zod every field is **required** unless you mark it `.optional()` (or give a `.default()`). Constraints chain fluently (`.min().max().email()`). The schema is an ordinary value you can export, compose, and reuse anywhere.
Type inference — the standout feature
TS
// Derive the static type FROM the schema — one source of truth:
type User = z.infer<typeof UserSchema>
// type User = {
// name: string; email: string; age?: number;
// role: 'user' | 'admin'; tags: string[]
// }
function save(user: User) { /* fully typed, matches the validator */ }`z.infer` keeps types and validation in lockstep
In a plain-JS validator, your runtime schema and your TypeScript `interface` are two separate things that drift apart. With `z.infer<typeof Schema>`, the type is *generated* from the schema — change a field and the type updates automatically, and the compiler flags every place that breaks. This single-source-of-truth property is why TypeScript projects gravitate to Zod.
Parsing — `parse` vs `safeParse`
JS
// parse: returns the typed data, or THROWS a ZodError on failure
const user = UserSchema.parse(req.body)
// safeParse: returns a result object, no throw — branch on success
const result = UserSchema.safeParse(req.body)
if (!result.success) {
return res.status(422).json({ errors: result.error.issues })
}
const user = result.data // typed + coerced + defaults appliedMethod | On success | On failure |
|---|---|---|
| Returns typed data | Throws |
|
|
|
| Same, async | For async refinements |
`parse` to let the error handler catch it; `safeParse` to branch inline
Use `parse` when you want a thrown `ZodError` to flow to your [central error handler](/nodejs/error-middleware) (translate it to a 422 there). Use `safeParse` when you want to handle the failure right where it happens without `try/catch`. Either way, the returned `data` is **coerced and defaulted** — use it, not the raw `req.body`.
Coercion, transforms, and refinements
JS
const QuerySchema = z.object({
// z.coerce turns the string "20" from a query string into a number:
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
const SignupSchema = z.object({
email: z.string().email().transform((s) => s.toLowerCase().trim()),
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: 'Passwords must match',
path: ['confirm'], // attach the error to the 'confirm' field
})`coerce` for query strings, `transform` to reshape, `refine` for cross-field rules
Everything from a [query string](/nodejs/query-parameters) is a string, so `z.coerce.number()` is essential there. `.transform()` reshapes a value *after* validation (lowercasing an email). `.refine()`/`.superRefine()` express rules a single field can't — password confirmation, conditional requirements — with a custom message and `path`. These cover the cross-field logic [Joi](/nodejs/joi) does with `.when()`/`.ref()`.
Strict objects — block mass assignment
JS
// Default: unknown keys are STRIPPED from the output.
UserSchema.parse({ name: 'Ada', email: 'a@b.com', isAdmin: true })
// → { name, email, role, tags } — 'isAdmin' silently removed
// .strict(): unknown keys cause a validation ERROR instead.
const Strict = UserSchema.strict()
Strict.parse({ name: 'Ada', email: 'a@b.com', isAdmin: true })
// → throws: Unrecognized key 'isAdmin'Zod strips unknown keys by default — `.strict()` to reject them
By default Zod **removes** keys not in the schema, so `result.data` never contains a smuggled `isAdmin` — a solid [mass-assignment](/nodejs/validation-intro) defense as long as you use `result.data` and not the original body. Use `.strict()` when you'd rather *reject* requests carrying unexpected fields (surfacing client bugs). Either is safe; what's dangerous is persisting the raw `req.body`.
Reusable Express middleware
JS
const validate = (schema, part = 'body') => (req, res, next) => {
const result = schema.safeParse(req[part])
if (!result.success) {
return res.status(422).json({
error: 'Validation failed',
details: result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
})
}
req[part] = result.data // cleaned, typed, defaulted
next()
}
app.post('/users', validate(UserSchema), createUser)
app.get('/users', validate(QuerySchema, 'query'), listUsers)Composing schemas
JS
const Address = z.object({ city: z.string(), zip: z.string() })
const Customer = UserSchema.extend({ // add fields
address: Address, // nest a schema
billing: Address.optional(),
})
const PublicUser = UserSchema.pick({ name: true, role: true }) // subset
const UpdateUser = UserSchema.partial() // all fields optional (for PATCH)Build big schemas from small ones
`.extend()`, `.pick()`, `.omit()`, `.partial()`, and `.merge()` let you derive related schemas without repetition — a `UpdateUser` for [PATCH](/nodejs/crud-operations) is just `UserSchema.partial()`, a public view is `.pick()`. Compose nested objects directly. This composability keeps a large API's validation DRY and consistent, with TypeScript types tracking every derivation.
Next
The middleware-native validator built for Express: [express-validator](/nodejs/express-validator).