Skip to content

Request Validation#

Type-safe request validation for req.body, req.query, and req.params using Zod schemas.

Validation Middleware#

import type { NextFunction, Request, Response } from "express";
import { StatusCodes } from "http-status-codes";
import { z, ZodError } from "zod";
import logger from "../utils/logger.js";

export function validateSchema<
  TBody extends z.ZodSchema = z.ZodNever,
  TQuery extends z.ZodSchema = z.ZodNever,
  TParams extends z.ZodSchema = z.ZodNever,
>(schemas: { params?: TParams; query?: TQuery; body?: TBody }) {
  return (req: Request, res: Response, next: NextFunction) => {
    const errs: {
      params?: ZodError;
      query?: ZodError;
      body?: ZodError;
    } = {};

    req.validated = {};

    // Validate params
    if (schemas.params) {
      const res = schemas.params.safeParse(req.params);
      if (res.success) {
        req.validated.params = res.data;
      } else {
        errs.params = res.error;
      }
    }

    // Validate query
    if (schemas.query) {
      const res = schemas.query.safeParse(req.query);
      if (res.success) {
        req.validated.query = res.data;
      } else {
        errs.query = res.error;
      }
    }

    // Validate body
    if (schemas.body) {
      const res = schemas.body.safeParse(req.body);
      if (res.success) {
        req.validated.body = res.data;
      } else {
        errs.body = res.error;
      }
    }

    // Return errors if any
    if (Object.keys(errs).length > 0) {
      const issues = {
        params: errs.params && formatZodError(errs.params),
        query: errs.query && formatZodError(errs.query),
        body: errs.body && formatZodError(errs.body),
      };

      logger.debug("Request validation failed", { issues });

      return res.status(StatusCodes.BAD_REQUEST).json({
        error: "Request validation failed",
        issues,
      });
    }

    next();
  };
}

function formatZodError(error: ZodError): any[] {
  return error.issues.map((issue) => ({
    path: issue.path.join(".") || "root",
    message: issue.message,
    code: issue.code,
    expected: "expected" in issue ? issue.expected : undefined,
    received: "received" in issue ? issue.received : undefined,
  }));
}

Type Helper#

type InferValidated<T extends Record<string, z.ZodSchema>> = {
  [K in keyof T]: z.infer<T[K]>;
};

export function getValidatedData<T extends Record<string, z.ZodSchema>>(
  req: Request,
  _schema: T // For type inference only
) {
  return req.validated as InferValidated<T>;
}

TypeScript Declarations#

// types/express.d.ts
import "express";

declare global {
  namespace Express {
    interface Request {
      validated?: {
        params?: any;
        query?: any;
        body?: any;
      };
    }
  }
}

Define Schemas#

// schemas/users.ts
import { z } from "zod";

export const userSchemas = {
  // List users - pagination query params
  list: {
    query: z.object({
      limit: z.coerce.number().min(1).max(100).default(20),
      offset: z.coerce.number().min(0).default(0),
    }),
  },

  // Get user by ID - validate params
  getById: {
    params: z.object({
      id: z.string().uuid(),
    }),
  },

  // Create user - validate body
  create: {
    body: z.object({
      name: z.string().min(2).max(100),
      email: z.string().email(),
      password: z.string().min(8),
      age: z.number().int().min(18).optional(),
    }),
  },

  // Update user - validate params + body
  update: {
    params: z.object({
      id: z.string().uuid(),
    }),
    body: z.object({
      name: z.string().min(2).max(100).optional(),
      email: z.string().email().optional(),
      age: z.number().int().min(18).optional(),
    }),
  },

  // Search users - validate query
  search: {
    query: z.object({
      q: z.string().min(1),
      limit: z.coerce.number().default(10),
      role: z.enum(["ADMIN", "USER", "GUEST"]).optional(),
    }),
  },
};

Usage in Routes#

import { Router } from "express";
import type { Request, Response } from "express";
import { validateSchema, getValidatedData } from "../middleware/validation.js";
import { userSchemas } from "../schemas/users.js";
import { requireAdmin, requireAuth } from "../middleware/auth.js";

const router = Router();

// List users with pagination
router.get(
  "/",
  requireAdmin,
  validateSchema(userSchemas.list),
  async (req: Request, res: Response) => {
    const { query } = getValidatedData(req, userSchemas.list);

    const users = await listUsers(query.limit, query.offset);
    res.json({ users });
  }
);

// Get user by ID
router.get(
  "/:id",
  requireAuth(),
  validateSchema(userSchemas.getById),
  async (req: Request, res: Response) => {
    const { params } = getValidatedData(req, userSchemas.getById);

    const user = await getUserById(params.id);
    res.json({ user });
  }
);

// Create user
router.post(
  "/",
  validateSchema(userSchemas.create),
  async (req: Request, res: Response) => {
    const { body } = getValidatedData(req, userSchemas.create);

    const user = await createUser(body);
    res.status(201).json({ user });
  }
);

// Update user
router.put(
  "/:id",
  requireAuth(),
  validateSchema(userSchemas.update),
  async (req: Request, res: Response) => {
    const { params, body } = getValidatedData(req, userSchemas.update);

    const user = await updateUser(params.id, body);
    res.json({ user });
  }
);

// Search users
router.get(
  "/search",
  requireAuth(),
  validateSchema(userSchemas.search),
  async (req: Request, res: Response) => {
    const { query } = getValidatedData(req, userSchemas.search);

    const users = await searchUsers(query.q, query.limit, query.role);
    res.json({ users });
  }
);

export { router as usersRouter };

Validation Error Response#

{
  "error": "Request validation failed",
  "issues": {
    "body": [
      {
        "path": "email",
        "message": "Invalid email",
        "code": "invalid_string",
        "expected": "string",
        "received": "test"
      },
      {
        "path": "password",
        "message": "String must contain at least 8 character(s)",
        "code": "too_small",
        "expected": 8,
        "received": 5
      }
    ]
  }
}

Common Validation Patterns#

Pagination#

const paginationSchema = z.object({
  limit: z.coerce.number().min(1).max(100).default(20),
  offset: z.coerce.number().min(0).default(0),
});

// URL: /api/users?limit=10&offset=20

UUID Params#

const idSchema = z.object({
  id: z.string().uuid(),
});

// URL: /api/users/123e4567-e89b-12d3-a456-426614174000

Search Query#

const searchSchema = z.object({
  q: z.string().min(1),
  limit: z.coerce.number().default(10),
  sortBy: z.enum(["createdAt", "name", "email"]).default("createdAt"),
  order: z.enum(["asc", "desc"]).default("desc"),
});

// URL: /api/users/search?q=john&sortBy=name&order=asc

Optional Fields#

const updateSchema = z.object({
  name: z.string().optional(),
  email: z.string().email().optional(),
  age: z.number().int().min(18).optional(),
});

// All fields are optional

Enum Validation#

const roleSchema = z.object({
  role: z.enum(["ADMIN", "USER", "MODERATOR", "GUEST"]),
});

Nested Objects#

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().regex(/^\d{5}$/),
  country: z.string().length(2), // ISO code
});

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  address: addressSchema,
});

Arrays#

const bulkCreateSchema = z.object({
  users: z.array(
    z.object({
      name: z.string().min(2),
      email: z.string().email(),
    })
  ).min(1).max(100),
});

Coercion (Query Params)#

// Query params come as strings, use .coerce to convert
const querySchema = z.object({
  limit: z.coerce.number(),    // "10" → 10
  active: z.coerce.boolean(),  // "true" → true
  tags: z.string().transform(s => s.split(",")), // "a,b,c" → ["a","b","c"]
});

Complete Validation Flow#

// 1. Client sends request
POST /api/users
{
  "name": "Alice",
  "email": "invalid-email",
  "password": "short"
}

// 2. Validation middleware runs
validateSchema(userSchemas.create)

// 3. Zod validates the body
const result = userSchemas.create.body.safeParse(req.body);

// 4. Validation fails
if (!result.success) {
  return res.status(400).json({
    error: "Request validation failed",
    issues: { body: [...] }
  });
}

// 5. If valid, attach to req.validated
req.validated.body = result.data;

// 6. Route handler gets typed data
const { body } = getValidatedData(req, userSchemas.create);
// body is fully typed: { name: string; email: string; password: string; age?: number }

Best Practices#

  1. Validate all inputs - params, query, and body
  2. Use coerce for query params - They come as strings
  3. Set defaults - Use .default() for optional values
  4. Use getValidatedData() - Get fully typed validated data
  5. Reuse schemas - Define once, use in multiple routes
  6. Validate before auth - Or after, depending on needs
  7. Return detailed errors - Help clients fix validation issues

Schema Organization#

schemas/
├── users.ts
├── posts.ts
├── comments.ts
├── common.ts      # Shared schemas (pagination, etc.)
└── index.ts       # Export all schemas
// schemas/common.ts
export const paginationSchema = z.object({
  limit: z.coerce.number().min(1).max(100).default(20),
  offset: z.coerce.number().min(0).default(0),
});

export const idParamSchema = z.object({
  id: z.string().uuid(),
});

// schemas/users.ts
import { paginationSchema, idParamSchema } from "./common.js";

export const userSchemas = {
  list: { query: paginationSchema },
  getById: { params: idParamSchema },
  // ...
};

Next Steps#