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
- Avoir lu l'article sur le filtrage et le tri
- Connaitre TypeScript et les bases d'Express
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 :
- Inference de type : le schema Zod généré le type TypeScript automatiquement
- Composable : tu assembles des schemas comme des Legos
- 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.coercepour 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
- Zod Documentation. https://zod.dev/
- Express.js Error Handling. https://expressjs.com/en/guide/error-handling.html
- OWASP Input Validation Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html