Request Validation (Zod/Joi)

Difficulty: Intermediate

Question

How do you validate incoming request data in a Node.js API? Compare Zod and Joi approaches.

Answer

Request validation ensures incoming data matches expected shapes before reaching business logic. Never trust client input. Validation libraries like Zod and Joi define schemas that describe the expected structure, types, and constraints of request data.

Zod is TypeScript-first and generates TypeScript types from schemas automatically using z.infer. This means your runtime validation and compile-time types are always in sync. Zod is smaller and faster than Joi, making it the preferred choice for modern TypeScript projects.

Joi is an older, battle-tested library with a fluent API. It has more built-in validators and customization options but does not generate TypeScript types natively. It is still widely used in JavaScript projects.

The standard pattern is a validation middleware factory that takes a schema and returns middleware. The middleware validates req.body, req.params, or req.query against the schema and either calls next() on success or returns a 400 error with details.

Code examples

Zod Validation with TypeScript

import { z } from 'zod';

// Define schema
const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8).regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    'Must contain uppercase, lowercase, and number'
  ),
  age: z.number().int().min(18).max(120).optional(),
  role: z.enum(['user', 'admin']).default('user'),
  tags: z.array(z.string()).max(5).optional(),
});

// Infer TypeScript type from schema
type CreateUserInput = z.infer<typeof createUserSchema>;
// { name: string; email: string; password: string; age?: number; role: 'user' | 'admin'; tags?: string[] }

// Validate
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({
    error: 'Validation failed',
    details: result.error.flatten().fieldErrors
  });
}
const validData: CreateUserInput = result.data;

Zod's safeParse returns success/failure without throwing. z.infer generates the TypeScript type, keeping validation and types perfectly in sync.

Validation Middleware Factory

import { z, ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';

// Generic validation middleware
function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse({
      body: req.body,
      query: req.query,
      params: req.params,
    });

    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.flatten().fieldErrors,
      });
    }

    // Replace with validated data
    req.body = result.data.body;
    req.query = result.data.query;
    req.params = result.data.params;
    next();
  };
}

// Usage in routes
const getUserSchema = z.object({
  params: z.object({ id: z.string().regex(/^\d+$/) }),
});

const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2),
    email: z.string().email(),
  }),
});

router.get('/users/:id', validate(getUserSchema), getUser);
router.post('/users', validate(createUserSchema), createUser);

A single validate() factory handles body, params, and query validation for any route. The validated data replaces the raw input on the request object.

Complex Schema Patterns

import { z } from 'zod';

// Nested objects
const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});

// Refinements for custom logic
const dateRangeSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
}).refine(
  (data) => new Date(data.endDate) > new Date(data.startDate),
  { message: 'End date must be after start date' }
);

// Transform: validate AND transform input
const searchSchema = z.object({
  page: z.string().transform(Number).pipe(z.number().int().positive()),
  limit: z.string().transform(Number).pipe(z.number().int().max(100)),
  sort: z.enum(['name', 'date', 'price']).default('date'),
});

// Union types
const paymentSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('card'), cardNumber: z.string() }),
  z.object({ type: z.literal('bank'), accountId: z.string() }),
]);

Zod supports refinements for cross-field validation, transforms for coercion, and discriminated unions for polymorphic data.

Key points

Concepts covered

Zod, Joi, Schema Validation, Type Safety, Input Sanitization