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.
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.
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 wraps the validator string library as chainable middleware.
npm install express-validatorimport { 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 describes the whole payload as a schema object, infers TypeScript types from it, and works the same on the client and the server.
npm install zodimport { 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.
| 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.
params and query too: an invalid ObjectId in the URL is the most common source of a 500 that should be a 400.src/validation/ next to the feature, and reuse the same schema for PATCH with .partial().Why is `matchedData(req)` safer than reading `req.body` directly after validation?
validationResult(); matchedData() returns only declared fields.safeParse(), coerces strings with z.coerce, and strips unknown keys by default.422 error body with field and message entries.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.