Phase 2 — Building REST APIs lesson-0011

Request Validation

Every piece of data entering your system from outside is untrusted. Validation is your first line of defence — it ensures your business logic only ever sees data in the shape it expects.

What to Validate and Where

Validation targets four distinct sources of input per request. Each is untrusted and must be validated independently:

  • req.body — the parsed JSON body. Can contain anything. Most critical to validate.
  • req.params — route segments like :id. Always strings — validate format and cast.
  • req.query — query string values. Always strings — must coerce numeric/boolean types.
  • req.headers — rarely validated via schema, but specific headers (like Content-Type) matter.
Never Trust req.body
A client can send { "isAdmin": true, "role": "superuser", "balance": -99999 } in any request. Without validation, your application might process these fields silently. Always define the exact shape you accept and reject anything that doesn't match.

Zod — The Standard

npm install zod

Zod is a TypeScript-first schema declaration and validation library. In JavaScript it gives you runtime type safety — schemas parse raw input and return validated, typed data.

const { z } = require('zod');

// parse() — throws ZodError on failure
const data = schema.parse(input);

// safeParse() — returns result object, never throws (use this in Express)
const result = schema.safeParse(input);
if (!result.success) {
  // result.error is a ZodError with detailed field-level messages
  console.log(result.error.flatten().fieldErrors);
  // { email: ['Invalid email'], age: ['Expected number, received string'] }
}
// result.data is the validated (and possibly transformed) value

Complete Zod Schema Reference

z.string()
.min(n) .max(n) .length(n) .email() .url() .uuid() .cuid() .regex(/pattern/) .startsWith(s) .endsWith(s) .trim() .toLowerCase() .toUpperCase() .datetime() .ip()
z.number()
.int() .positive() .negative() .nonnegative() .min(n) .max(n) .multipleOf(n) .finite() .safe()
z.coerce.number()
Converts strings to numbers before validating. Essential for query params: '42' → 42. Use for all req.query numeric fields.
z.boolean()
Strictly true/false. Does NOT coerce strings. Use z.coerce.boolean() or z.enum(['true','false']).transform(v => v === 'true') for query params.
z.enum([])
z.enum(['draft','published','archived']) — value must be one of these exactly. TypeScript gets the union type automatically.
z.nativeEnum()
Validates against a TypeScript/JS enum object. z.nativeEnum(UserRole)
z.literal()
z.literal('active') — accepts only this exact value. Useful in discriminated unions.
z.array()
z.array(z.string()).min(1).max(10).nonempty() — array with item schema and length bounds.
z.object()
.strict() — rejects unknown keys (recommended). .partial() — all fields optional (for PATCH). .pick({ name: true }) — subset. .omit({ password: true }) — exclude fields.
z.union()
z.union([z.string(), z.number()]) — value must match one of the schemas.
z.discriminatedUnion()
Efficient union based on a discriminator key: z.discriminatedUnion('type', [dogSchema, catSchema])
z.record()
z.record(z.string(), z.number()) — arbitrary key-value map where all values match a schema.
z.date()
Validates Date objects. Use z.string().datetime() for ISO string dates from JSON.
.optional()
Field may be absent (undefined). Type becomes T | undefined.
.nullable()
Value may be null. Type becomes T | null.
.nullish()
Shorthand for .optional().nullable(). Type becomes T | null | undefined.
.default(val)
If field is absent/undefined, use this default. Does NOT trigger for null.
.transform(fn)
Run a function on the value after validation: z.string().transform(s => s.trim().toLowerCase())
.refine(fn)
Custom validation: z.string().refine(s => s !== 'admin', 'Username "admin" is reserved')
.superRefine(fn)
Advanced: multiple custom errors, cross-field validation within one schema.
z.preprocess(fn, schema)
Transform the input BEFORE validation. Use when the raw input needs cleaning first.

Real-World Schema Examples

const { z } = require('zod');

// User registration — strict shape, transform email
const RegisterSchema = z.object({
  name:     z.string().min(1).max(100).trim(),
  email:    z.string().email().toLowerCase(),
  password: z.string().min(8).max(72)  // bcrypt max is 72 chars
             .regex(/[A-Z]/, 'Needs uppercase')
             .regex(/[0-9]/, 'Needs a number'),
  role:     z.enum(['user', 'admin']).default('user'),
}).strict(); // reject any extra fields the client sends

// PATCH update — all fields optional, at least one required
const UpdateUserSchema = RegisterSchema
  .omit({ password: true, role: true }) // can't change password/role here
  .partial()                              // all remaining fields optional
  .refine(                               // but at least one must be provided
    (data) => Object.keys(data).length > 0,
    { message: 'At least one field is required' }
  );

// Query params with coercion
const ListUsersSchema = z.object({
  page:   z.coerce.number().int().min(1).default(1),
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  sort:   z.enum(['name', 'email', 'createdAt']).default('createdAt'),
  order:  z.enum(['asc', 'desc']).default('desc'),
  search: z.string().max(100).optional(),
  role:   z.enum(['user', 'admin']).optional(),
});

// Cross-field validation — confirm password
const PasswordResetSchema = z.object({
  password:        z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords do not match', path: ['confirmPassword'] }
);

The validate() Middleware Factory

Build one reusable middleware factory and use it across every route:

// src/middleware/validate.js
const { z } = require('zod');
const { ValidationError } = require('../errors/AppError');

/**
 * validate(schema) — returns an Express middleware that:
 * 1. Parses and validates req.body, req.params, req.query
 * 2. Replaces them with validated/coerced/defaulted values
 * 3. Calls next() on success, next(ValidationError) on failure
 *
 * Schema shape: z.object({ body: ..., params: ..., query: ... })
 */
const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse({
    body:   req.body,
    params: req.params,
    query:  req.query,
  });

  if (!result.success) {
    const details = result.error.flatten().fieldErrors;
    return next(new ValidationError(details));
  }

  // Replace with validated values — Zod applies defaults, transforms, coercions
  req.body   = result.data.body   ?? req.body;
  req.params = result.data.params ?? req.params;
  req.query  = result.data.query  ?? req.query;

  next();
};

module.exports = validate;
// src/schemas/users.js — all schemas for the users resource
const { z } = require('zod');

// Reusable sub-schemas
const userId = z.object({ params: z.object({ id: z.coerce.number().int().positive() }) });

module.exports = {
  create: z.object({ body: CreateUserSchema }),
  update: z.object({ body: UpdateUserSchema, params: userId.shape.params }),
  getById: userId,
  list:   z.object({ query: ListUsersSchema }),
};
// src/routes/users.js — clean route file
const validate = require('../middleware/validate');
const schemas  = require('../schemas/users');
const ctrl     = require('../controllers/users');

router.get('/',      validate(schemas.list),    ctrl.getAll);
router.get('/:id',   validate(schemas.getById), ctrl.getById);
router.post('/',     validate(schemas.create),  ctrl.create);
router.patch('/:id', validate(schemas.update),  ctrl.update);

Custom Error Messages

// Zod accepts a custom message as the second argument to most validators
z.string().min(8, 'Password must be at least 8 characters')
z.string().email('Please enter a valid email address')
z.number().positive('Price must be a positive number')
z.enum(['admin', 'user'], { message: 'Role must be admin or user' })

// Custom message via options object
z.string({ required_error: 'Email is required', invalid_type_error: 'Email must be a string' })
 .email('Invalid email format')

Stripping Unknown Fields — Security by Default

❌ Allows extra fields
// Default z.object() behaviour:
// strips unknown keys silently
// but doesn't warn you they were there
z.object({ name: z.string() })
// { name: 'Alice', isAdmin: true }
// → { name: 'Alice' } (silently)
✅ .strict() rejects unknown
// .strict() throws if any key
// in the input isn't in the schema
z.object({ name: z.string() })
 .strict()
// { name: 'Alice', isAdmin: true }
// → ZodError: unrecognized key isAdmin
Use .strict() on All Input Schemas
If a client sends fields you didn't expect, you want to know about it — not silently accept them. Unknown fields in input are usually either bugs or attempted attacks. Use .strict() on request body schemas. Note: don't use it on query schemas since browsers may add extra params.

Validating the Response — Output Schemas

Senior engineers also validate outbound data. An output schema strips sensitive fields (like password) before the response is sent, even if code accidentally selects them:

// Define what's safe to send to clients
const UserResponseSchema = z.object({
  id:        z.number(),
  name:      z.string(),
  email:     z.string(),
  role:      z.string(),
  createdAt: z.string(),
  // password: intentionally omitted
  // internalFlags: intentionally omitted
});

const sanitiseUser = (user) => UserResponseSchema.parse(user);

// In controller
const user = await db.user.findUnique({ where: { id } });
res.json({ data: sanitiseUser(user) }); // guaranteed no password in response

🧠 Check Your Understanding


Go Deeper

Primary source: zod.dev — the docs are excellent, especially the error handling and advanced sections.

Error formatting: Zod Error Handling — learn to format errors exactly as your API spec requires.

Ask your teacher: "Show me z.discriminatedUnion for a payment schema that handles both card and bank transfer." or "How do I write a Zod schema for a recursive comment tree?"