The two alternatives you will meet in existing Express 24,430 code take opposite approaches. Both were run against the same bad body, { title: " ", year: 2100, isbn: "978" }.
express-validator 6,233 (github.com/express-validator/express-validator (https://github.com/express-validator/express-validator 6,233 ), npm 2,036 i express-validator, 7.3.2) is not a schema library: it is middleware built on the validator package, so each rule is a chain attached to one field, and the chains are the route's middleware array.
app.post('/api/books', [
body('title').trim().notEmpty().withMessage('title is required')
.bail().isLength({ max: 200 }),
body('year').isInt({ min: 1450, max: 2026 }).withMessage('year out of range').toInt(),
body('isbn').matches(/^\d{13}$/).withMessage('ISBN must be 13 digits')
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
res.json({ data: matchedData(req) }); // only fields a validator claimed
});Each entry of errors.array() is { type, value, msg, path, location }; the body above yields three. Chains run left to right and sanitizers mutate as they go: trim() is why the reported value for title is "" rather than two spaces, toInt() is why a valid "2015" comes back as 2015, and bail() stops a chain after its first failure.
Joi 21,172 (github.com/hapijs/joi (https://github.com/hapijs/joi 21,172 ), npm i joi, 18.2.9) is a schema library like Zod 44,027 , older, with a fluent builder and no TypeScript inference.
const schema = Joi.object({
title: Joi.string().trim().min(1).max(200).required(),
year: Joi.number().integer().min(1450).max(2026).required(),
isbn: Joi.string().pattern(/^\d{13}$/, 'ISBN').required()
});
const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true });Each entry in error.details carries a dotted path, a message, and a type — here string.empty, number.max and string.pattern.name, the last reading "isbn" with value "978" fails to match the ISBN pattern. Branch on type: it is stable, the message is not. abortEarly: false is mandatory in an API, since Joi's default stops at the first error; stripUnknown gives you Zod's key-dropping behavior; convert makes .trim() a sanitizer.
| Library | Version | Deps | Weekly downloads | Best at |
|---|---|---|---|---|
| zod | 4.6.5 | 0 | 209.2 M | TypeScript inference |
| joi | 18.2.9 | 7 | 19.7 M | Conditional rules |
| express-validator | 7.3.2 | 2 | 1.2 M | Retrofitting a route |
All three are MIT licensed. The commercial gateways that sell request validation (Kong 3,957 , Apigee 1 , AWS API Gateway 24 ) check requests against an OpenAPI document at the network edge, complementing a library rather than replacing one: a gateway cannot know that an ISBN must not already exist. Starting fresh, choose Zod — one object is validator, type and OpenAPI schema at once.