NodeJSValidation with Zod

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 applied

Method

On success

On failure

parse(data)

Returns typed data

Throws ZodError

safeParse(data)

{ success: true, data }

{ success: false, error }

parseAsync / safeParseAsync

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