Zod contre express-validator : Deux approches pour la validation dans Express
Compare la validation des requêtes basée sur le schéma avec Zod à l’intermédiaire express-validator fondé sur des chaînes, en abordant la configuration, le formatage des erreurs et les pièges courants.
Gérer les entrées non fiables est l’un des premiers problèmes que toute API Express doit résoudre, et il existe plus d’une façon de le faire — des bibliothèques basées sur des schémas aux validateurs plus procéduraux, fonctionnant par chaînes. Cet article examine ces deux approches, en commençant par une méthode pilotée par des schémas développée avec Zod.
Valider les requêtes avec Zod
Express ne effectue aucune validation des données reçues par lui-même. Sans vérification au niveau de la frontière, les gestionnaires de route reçoivent tels quels req.body, req.query et req.params : des champs numériques qui sont en réalité des chaînes de caractères, des champs complètement absents, ainsi que des en-têtes dont la structure ne pose problème qu’une fois qu’ils atteignent votre logique métier.
Zod résout ce problème en vous permettant de décrire les formes de données attendues sous forme de schémas TypeScript. Vous définitez un schéma une seule fois, en dérivez un type statique à l’aide de z.infer, puis vous analysez les données reçues au niveau de votre couche HTTP afin que tout ce qui suit ne voie que des données valides. Tout élément non conforme à la validation peut générer une réponse HTTP 400 avant même que le code de traitement ne s’exécute.
Les exemples ci-dessous utilisent Zod 4 (z.email(), z.uuid(), z.coerce), ainsi qu’un middleware de validation Express, un outil commun pour le formatage des erreurs, et une liste des pièges fréquents.
Prérequis
Vous aurez besoin de Node.js version 26, de Zod 4 (npm i zod) et d’Express avec ses définitions de types (npm i express et npm i -D @types/express). La syntaxe en chaîne plus ancienne de Zod 3, telle que z.string().email(), fonctionne encore dans la version 4 mais est dépréciée — préférez les fonctions de niveau supérieur plus récentes présentées ci-dessous.
Déclaration des schémas
// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});
z.coerce.number() est utile pour les valeurs de chaîne de requête, car tout ce qui est lu depuis une requête HTTP arrive sous forme de chaîne, quel que soit son type logique. Préférez safeParse à parse aux points de transition afin de garder le contrôle sur l’état HTTP résultant et le corps de la réponse.
Formatage cohérent des erreurs
Convertissez ZodError.issues en une structure JSON stable plutôt que de formater les erreurs séparément dans chaque route. Zod 4 propose également z.flattenError() pour obtenir un tableau d’erreurs plat avec des clés de champ, ainsi que z.treeifyError() pour une structure imbriquée reflétant le schéma.
// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}
Moyen d’validation
Vérifiez body, query et params avant l’exécution du gestionnaire de route, puis écrivez à nouveau les valeurs analysées afin que le gestionnaire reçoive des données typées et converties.
// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}
Connectez-le à chaque route de cette manière :
app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});
Les résultats des requêtes et des paramètres sont stockés dans res.locals car les types d’Express traitent req.query/req.params comme des tableaux de chaînes simples ; les remplacer directement entraînerait un conflit avec cette typage.
Péripéties
- Les chaînes de requête sont toujours des chaînes de caractères — utilisez
z.coerce(ouz.string()associé à une transformation) pour les nombres et les booléens. parsegénère une erreur bruteZodError; soit vous la capturez et la convertissez en erreur 400 vous-même, soit utilisezsafeParseà la place.- Par défaut, les schémas d’objets Zod suppriment les clés inconnues ; ajoutez
.strict()pour les rejeter. - Les types inférés tels que
CreateUserInputn’existent qu’à l’état de compilation — assurez-vous également de parser les données aux points de limite. - Dans Zod 4,
z.uuid()vérifie selon la spécification UUID plus récente et stricte ; si vous avez simplement besoin d’un schéma générique de huit, quatre, quatre, quatre et douze chiffres hexadécimaux sans les règles plus strictes, optez plutôt pourz.guid().
Une alternative : validation basée sur des middleware avec express-validator
Zod n’est pas la seule façon d’éviter que des données incorrectes ne parviennent à vos gestionnaires de requêtes. Les applications Express s’appuient depuis longtemps sur express-validator, une bibliothèque conçue spécifiquement comme middleware pour Express, qui adopte une approche différente pour résoudre le même problème.
Imaginez une demande de registration comme celle-ci :
{
"email": "hello",
"password": "123"
}
Si un contrôleur examine directement ce chargement de données, chaque champ nécessite une vérification manuelle, ce qui se transforme rapidement en une série de conditions mélangeant validation et logique métier :
if (!email) ...
if (!email.includes("@")) ...
if (!password) ...
if (password.length < 8) ...
express-validator déplace cette logique hors du contrôleur et la place dans une étape de middleware dédiée, de sorte que la requête passe par la validation avant même d’atteindre votre gestionnaire :
Request
↓
Validation
↓
Controller
↓
Business Logic
Cette séparation est l’essence même de la bibliothèque : votre contrôleur peut ainsi se concentrer uniquement sur sa fonction principale.
Pour commencer, installez le paquet :
npm install express-validator
Importez l’aide-mémoire body et créez une chaîne de validation pour chaque champ qui vous intéresse :
import { body } from "express-validator";
export const registerValidator = [
body("email")
.isEmail()
.withMessage("Invalid email"), body("password")
.isLength({ min: 8 })
.withMessage("Password must contain at least 8 characters"), body("username")
.notEmpty()
.withMessage("Username is required"),
];
Attachez ce middleware à la route, avant le contrôleur :
router.post(
"/register",
registerValidator,
registerController
);
La définition des vérifications ne suffit pas à elle seule — vous devez encore lire les erreurs collectées lors de la validation :
import { validationResult } from "express-validator";
const errors = validationResult(req);if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array(),
});
}
Avec cette vérification en place, les chargements inválides sont rejetés avec un code 400 avant que toute logique métier ne s’exécute.
Les validateurs intégrés couvrent bien les cas courants :
.isEmail()
.isLength()
.notEmpty()
.isInt()
Mais les applications réelles ont souvent besoin de règles que la bibliothèque ne peut pas connaître à l’avance — par exemple, lors de l’inscription, vous pouvez devoir vérifier si un e-mail est déjà pris. C’est à cela que sert .custom() :
body("email")
.isEmail()
.bail()
.custom(async (email) => {
const user = await User.findOne({ email });
if (user) {
throw new Error("Email already registered");
} return true;
});
Les validateurs personnalisés peuvent être asynchrones, ce qui les rend adaptés aux recherches dans la base de données et à d’autres vérifications dépendant de votre propre logique métier. Notez l’appel à .bail() avant la vérification personnalisée : il saute le reste de la chaîne, y compris la recherche asynchrone, si l’adresse e-mail a déjà échoué à la vérification .isEmail(), évitant ainsi un aller-retour inutile vers la base de données.
Le choix entre ces deux bibliothèques — ou Joi, une autre option éprouvée — dépend de ce qui convient à votre stack : express-validator convient aux projets déjà construits autour des middleware d’Express, Zod convient aux bases de code orientées TypeScript et basées sur des schémas, tandis que Joi est une alternative polyvalente et mature. Il n’existe pas de choix universellement correct ; cela dépend de l’architecture de votre application.
Quel que soit l’outil que vous choisirez, les forces d’express-validator résident dans ses validateurs intégrés, ses outils de nettoyage, ses validateurs personnalisés et asynchrones, son modèle de middleware ainsi que sa gestion centralisée des erreurs. Un pipeline de requêtes Express propre ressemble généralement à ceci :
Request
↓
Validator
↓
Controller
↓
Service
↓
Database
L’objectif n’est pas seulement de vérifier qu’une chaîne ressemble à une adresse e-mail — il s’agit plutôt de rejeter les entrées incorrectes le plus tôt possible afin que le reste de l’application reste propre.
Lectures complémentaires
- Les propositions TC39 en 2026 : Explication des décorateurs, de Temporal et des signaux — Une analyse pratique de trois propositions TC39 — les décorateurs natifs, l’API Temporal et les signaux — et de leur impact sur les développeurs JavaScript et TypeScript full-stack.