Accueil / Articles / Zod contre express-validator : Deux approches pour la validation dans Express

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.

1438 mots

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 (ou z.string() associé à une transformation) pour les nombres et les booléens.
  • parse génère une erreur brute ZodError ; soit vous la capturez et la convertissez en erreur 400 vous-même, soit utilisez safeParse à 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 CreateUserInput n’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 pour z.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

  • RFC 9457 expliqué : standardisation des réponses d’erreur de l’API HTTP — Découvrez comment le format Problem Details de RFC 9457 standardise les réponses d’erreur des API HTTP et comment l’implémenter correctement dans une application NestJS.
  • Partager un seul schéma Zod entre votre frontend React et votre backend Node — Apprenez comment un seul schéma Zod peut valider les formulaires React, les réponses API, les corps de requête Express et les variables d’environnement tout en générant des types TypeScript correspondants.
  • req-guard-lite : Un rate limiter minimal en TypeScript de première classe pour Express — Découvrez comment fonctionne un rate limiter léger sans dépendances pour Express, des paramètres par défaut en mémoire jusqu’à l’extension avec Redis et aux générateurs de clés personnalisés.