REST API design - 08 - La validation avec Zod, gardien de ton API

Validation d'input avec Zod, messages d'erreur structures, coercion et middleware Express.

  1. 01 REST API design - 00 - Pourquoi le design de ton API change tout
  2. 02 REST API design - 01 - Les principes REST que personne ne lit
  3. 03 REST API design - 02 - Des URLs qui ont du sens
  4. 04 REST API design - 03 - Les méthodes HTTP, pour de vrai
  5. 05 REST API design - 04 - Les codes de statut HTTP qu'il faut connaître
  6. 06 REST API design - 05 - Body, headers et le diable dans les détails
  7. 07 REST API design - 06 - La pagination, ou comment ne pas tuer ta base
  8. 08 REST API design - 07 - Filtrage et tri sans prise de tête
  9. 09 REST API design - 08 - La validation avec Zod, gardien de ton API
  10. 10 REST API design - 09 - Erreurs : un format que tes clients vont adorer
  11. 11 REST API design - 10 - Authentification : JWT, API keys et OAuth2
  12. 12 REST API design - 11 - Versioning : quand et comment faire évoluer ton API
  13. 13 REST API design - 12 - CORS : comprendre et debugger les erreurs cross-origin
  14. 14 REST API design - 13 - Rate limiting : protéger ton API sans frustrer tes clients
  15. 15 REST API design - 14 - Caching : les bonnes réponses sont celles qu'on n'envoie pas
  16. 16 REST API design - 15 - Upload de fichiers : multipart, signed URLs et streaming
  17. 17 REST API design - 16 - Relations entre ressources : embarquer, lier ou les deux
  18. 18 REST API design - 17 - HATEOAS : des liens dans tes réponses
  19. 19 REST API design - 18 - OpenAPI : le contrat entre ton API et le monde
  20. 20 REST API design - 19 - Documentation : une API non documentee est une API inutile
  21. 21 REST API design - 20 - Testing : tester ton API sans devenir fou
  22. 22 REST API design - 21 - Webhooks : quand c'est ton API qui appelle
  23. 23 REST API design - 22 - Performance : quand chaque milliseconde compte
  24. 24 REST API design - 23 - Sécurité : les attaques que tu vas subir (et comment t'en protéger)
  25. 25 REST API design - 24 - Glossaire : tous les termes REST API en un seul endroit

08 - La validation avec Zod, gardien de ton API

Ce que tu vas apprendre

  • Comment valider les inputs d'une API REST avec Zod
  • La différence entre erreurs de validation et erreurs métier
  • La coercion de types pour les query params
  • Comment créer un middleware de validation réutilisable

Prerequisites


Le bug que la validation aurait empeche

Un vendredi a 17h (évidemment), un utilisateur a reussi a créer un compte avec l'email "pas un email". Pas de validation cote API. Le frontend avait un contrôle, mais quelqu'un avait fait un appel direct avec curl. Le mail de bienvenue a echoue, le worker de notification s'est bloque, et la queue de mails s'est remplie de retries. Tout ca parce que personne n'avait valide un champ en entree.

La validation cote client, c'est de l'UX. La validation cote serveur, c'est de la sécurité. Les deux sont nécessaires, mais seule la seconde est fiable.

Pourquoi Zod

Il existe des dizaines de librairies de validation en TypeScript. J'utilise Zod pour trois raisons :

  1. Inference de type : le schema Zod généré le type TypeScript automatiquement
  2. Composable : tu assembles des schemas comme des Legos
  3. Messages d'erreur : structures, parsables, personnalisables
typescriptimport { z } from "zod";

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
  age: z.number().int().min(13).max(150).optional(),
});

// Le type est infere automatiquement
type CreateUserInput = z.infer<typeof createUserSchema>;
// { name: string; email: string; role: "admin" | "editor" | "viewer"; age?: number }

Validation du body

typescriptrouter.post("/api/users", async (req, res) => {
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
        code: issue.code,
      })),
    });
  }

  // result.data est type-safe
  const user = await userService.create(result.data);
  res.status(201).json(user);
});

La réponse d'erreur est structuree et parsable par le client :

json{
  "error": "Validation failed",
  "details": [
    {
      "field": "email",
      "message": "Invalid email",
      "code": "invalid_string"
    },
    {
      "field": "name",
      "message": "String must contain at least 2 character(s)",
      "code": "too_small"
    }
  ]
}

La coercion : le piège des query params

Les query params arrivent toujours en string. ?page=2 donne req.query.page === "2" (string, pas number). Sans coercion, ta validation va rejeter des requêtes valides.

typescript// Sans coercion : ca casse
const paginationSchema = z.object({
  page: z.number().int().min(1), // "2" n'est pas un number -> erreur
});

// Avec coercion : ca marche
const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

// Utilisation
const result = paginationSchema.safeParse(req.query);
// { page: 2, limit: 20 } -- les strings sont converties en numbers

La coercion marche aussi pour les booleens et les dates :

typescriptconst filtersSchema = z.object({
  active: z.coerce.boolean().optional(),       // "true" -> true
  createdAfter: z.coerce.date().optional(),    // "2026-01-01" -> Date
  minPrice: z.coerce.number().min(0).optional(),
});

Validation vs erreurs métier

Une distinction que je fais systématiquement : les erreurs de validation (format) renvoient 400, les erreurs métier renvoient 409 ou 422.

typescript// Erreur de validation (400) : le format est mauvais
// "email": "pas un email"
// -> 400 Bad Request

// Erreur metier (409) : le format est bon, mais la logique dit non
// "email": "alice@example.com" (deja pris)
// -> 409 Conflict

// Erreur metier (422) : le format est bon, mais ca n'a pas de sens
// "startDate": "2026-03-30", "endDate": "2026-03-29"
// -> 422 Unprocessable Entity

En code, ca donne une séparation nette :

typescriptrouter.post("/api/events", async (req, res) => {
  // Etape 1 : validation de format (Zod)
  const result = createEventSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues,
    });
  }

  // Etape 2 : validation metier
  if (result.data.endDate <= result.data.startDate) {
    return res.status(422).json({
      error: "End date must be after start date",
    });
  }

  // Etape 3 : conflits
  const overlap = await eventService.findOverlapping(
    result.data.startDate,
    result.data.endDate
  );
  if (overlap) {
    return res.status(409).json({
      error: "Event overlaps with existing event",
      conflictingEventId: overlap.id,
    });
  }

  const event = await eventService.create(result.data);
  res.status(201).json(event);
});

Un middleware de validation réutilisable

Pour ne pas répéter le pattern safeParse + 400 dans chaque route :

typescriptimport { z, ZodSchema } from "zod";
import { Request, Response, NextFunction } from "express";

function validate(schemas: {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}) {
  return (req: Request, res: Response, next: NextFunction) => {
    const errors: { location: string; issues: z.ZodIssue[] }[] = [];

    if (schemas.body) {
      const result = schemas.body.safeParse(req.body);
      if (!result.success) {
        errors.push({ location: "body", issues: result.error.issues });
      } else {
        req.body = result.data;
      }
    }

    if (schemas.query) {
      const result = schemas.query.safeParse(req.query);
      if (!result.success) {
        errors.push({ location: "query", issues: result.error.issues });
      } else {
        (req as any).validatedQuery = result.data;
      }
    }

    if (schemas.params) {
      const result = schemas.params.safeParse(req.params);
      if (!result.success) {
        errors.push({ location: "params", issues: result.error.issues });
      }
    }

    if (errors.length > 0) {
      return res.status(400).json({
        error: "Validation failed",
        details: errors,
      });
    }

    next();
  };
}

// Utilisation
router.post(
  "/api/users",
  validate({ body: createUserSchema }),
  async (req, res) => {
    // req.body est deja valide et type
    const user = await userService.create(req.body);
    res.status(201).json(user);
  }
);

router.get(
  "/api/products",
  validate({ query: productQuerySchema }),
  async (req, res) => {
    const products = await productService.findAll((req as any).validatedQuery);
    res.json({ data: products });
  }
);

Schemas reutilisables et composition

Zod brille quand tu composes des schemas :

typescriptconst addressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  postalCode: z.string().regex(/^\d{5}$/),
  country: z.string().length(2),
});

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  address: addressSchema.optional(),
});

// Schema de mise a jour : tous les champs optionnels
const updateUserSchema = createUserSchema.partial();

// Schema strict : pas de champs inconnus
const strictSchema = createUserSchema.strict();

Le .partial() est un gain de temps énorme. Tu définis le schema de création une fois, et le schema de PATCH est généré automatiquement.

Tu trouveras des patterns de validation avances sur paltemps.fr.

Résumé

  • Valide toujours cote serveur, meme si le client valide aussi
  • Zod donne la validation et les types TypeScript en une seule définition
  • Utilise z.coerce pour les query params qui arrivent en string
  • 400 pour les erreurs de format, 409/422 pour les erreurs métier
  • Un middleware de validation générique évité la répétition dans chaque route
  • .partial() généré automatiquement le schema de mise à jour

Precedent : Filtrage et tri | Suivant : Gestion des erreurs

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.