Request Validation with express-validator and Zod

Intermediate
13 min

Request Validation with express-validator and Zod

Every value in req.body, req.params and req.query is untrusted text typed by a client. Validation checks that shape and types match what your service expects and rejects bad requests early with a helpful message. After this lesson you will be able to validate and sanitise input with express-validator, do the same with Zod schemas, and return consistent 422 error responses from a reusable middleware.

Where validation belongs

Validation is HTTP-level work, so it runs as middleware between the route and the controller. The controller then trusts its input, and the service only enforces business rules (such as "email already registered") that need a database.

javascript
router.post("/users", validateCreateUser, userController.create);

Use 400 for malformed requests (unparseable JSON) and 422 Unprocessable Content for well-formed requests with invalid values, and stay consistent.

express-validator

express-validator wraps the validator string library as chainable middleware.

bash
npm install express-validator
javascript
import { body, validationResult, matchedData } from "express-validator"; export const validate = (req, res, next) => { const result = validationResult(req); if (result.isEmpty()) return next(); res.status(422).json({ error: "Validation failed", details: result.array().map(({ path, msg }) => ({ field: path, message: msg })), }); }; export const createUserRules = [ body("email").isEmail().withMessage("Valid email required").normalizeEmail(), body("password").isString().isLength({ min: 8 }).withMessage("At least 8 characters"), body("age").optional().isInt({ min: 13 }).withMessage("Must be 13+").toInt(), body("role").optional().isIn(["user", "admin"]), ]; router.post("/users", createUserRules, validate, (req, res) => { const data = matchedData(req); // only validated fields, with sanitizers applied res.status(201).json({ data }); });

Each chain reads one field, runs validators (isEmail, isLength, isIn, isInt) and sanitizers (trim, normalizeEmail, toInt). withMessage() labels the validator immediately before it. matchedData() returns only the fields you declared, which protects against clients sending extra properties such as isAdmin: true.

Asynchronous checks such as "email already registered" use .custom(async (value) => { ... }) and reject by throwing.

Zod

Zod describes the whole payload as a schema object, infers TypeScript types from it, and works the same on the client and the server.

bash
npm install zod
javascript
import { z } from "zod"; export const createUserSchema = z.object({ body: z.object({ email: z.email(), password: z.string().min(8, "At least 8 characters"), age: z.coerce.number().int().min(13).optional(), role: z.enum(["user", "admin"]).default("user"), }), }); export const validate = (schema) => (req, res, next) => { const result = schema.safeParse({ body: req.body, params: req.params, query: req.query }); 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.validated = result.data; next(); }; router.post("/users", validate(createUserSchema), userController.create);

safeParse() never throws; it returns { success, data } or { success, error }. z.coerce.number() converts the string "18" from a query or form into a number. z.object() strips unknown keys by default, which gives you the same mass-assignment protection as matchedData().

Store the parsed result on req.validated (or res.locals). In Express 5, req.query is a read-only getter, so assigning the coerced query object back to it throws.

Choosing between them

| Aspect | express-validator | Zod | | --- | --- | --- | | Style | Per-field middleware chains | One schema per payload | | TypeScript | Manual types | Types inferred with z.infer | | Reuse outside Express | Limited | Same schema in React forms, jobs, tests | | Sanitizers | Built in (trim, escape, normalizeEmail) | .trim(), .toLowerCase(), z.coerce | | Error shape | { path, msg, location } | { path[], message, code } |

New projects, especially TypeScript ones, usually pick Zod; express-validator remains a good fit for existing JavaScript codebases.

Tips

  • Validate params and query too: an invalid ObjectId in the URL is the most common source of a 500 that should be a 400.
  • Keep schemas in src/validation/ next to the feature, and reuse the same schema for PATCH with .partial().
Quick Quiz
Question 1 of 3

Why is `matchedData(req)` safer than reading `req.body` directly after validation?

Key Takeaways

  • Validate at the middleware layer so controllers and services receive trusted, typed input.
  • express-validator uses per-field chains plus validationResult(); matchedData() returns only declared fields.
  • Zod validates a whole payload with safeParse(), coerces strings with z.coerce, and strips unknown keys by default.
  • Return a consistent 422 error body with field and message entries.
  • Validate params and query as well as the body, and never write to req.query in Express 5.

Next lesson: Pagination, Filtering and Sorting — turn ?page=2&sort=-createdAt&status=done into safe database queries with a predictable response envelope.

Request Validation with express-validator and Zod - Express.js | CodeYourCraft | CodeYourCraft