Zod : valider ses données en TypeScript (guide en français)
Il y a un moment très précis où TypeScript vous lâche : quand une donnée arrive de l'extérieur. Une réponse d'API, un formulaire, une valeur relue dans le stockage du navigateur. Vous avez écrit un type, vous l'avez soigné, et pourtant rien ne garantit que ce qui arrive lui ressemble. Le type existait pendant que vous codiez ; il a disparu avant que le programme tourne.
Zod répond à ce problème d'une façon qui surprend au début : au lieu d'écrire un type puis d'écrire une vérification, vous écrivez un schéma, et le type se déduit de ce schéma. Une seule description, deux usages. Pour situer l'outil, un chiffre relevé le 19 septembre 2026 sur l'API publique du registre npm : sur la semaine du 12 au 18 septembre, le paquet zod a été téléchargé 213 162 942 fois, contre 19,3 millions pour joi et 8,4 millions pour yup. Ce guide part du problème concret plutôt que de la liste des fonctions.
Méthode habituelle de ce blog : tout le code de cette page a été exécuté pendant la rédaction, le 19 septembre 2026, avec Zod 4.6.5 (le tag latest de npm ce jour-là, publié le 13 septembre 2026 en heure UTC), TypeScript 7.0.2, Node.js 20.19.4 et tsx 4.23.13, sur Windows 11. Les sorties sont citées telles quelles. Si vous débutez complètement en TypeScript, le projet todo-list en TypeScript est un meilleur point de départ ; ici, on suppose que vous savez ce qu'est un type et une interface.
Votre type disparaît à la compilation, pas vos données
Commençons par le programme que tout le monde a écrit au moins une fois. On récupère du JSON, on annonce au compilateur ce qu'il contient, et on s'en sert.
type Utilisateur = {
id: number;
email: string;
age: number;
};
// Ce que le serveur renvoie vraiment (id et age en texte, email absent).
const reponseDuServeur = '{"id":"42","age":"31"}';
const utilisateur = JSON.parse(reponseDuServeur) as Utilisateur;
console.log("id + 1 =", utilisateur.id + 1);
console.log("email en minuscules :", utilisateur.email.toLowerCase());Ce fichier compile sans la moindre erreur : sur le banc de cet article, tsc --noEmit sort avec le code 0 et n'affiche rien. C'est normal, et c'est le cœur du malentendu : as Utilisateur n'est pas une conversion ni une vérification, c'est une affirmation. Vous dites au compilateur « fais-moi confiance », il vous croit, et il s'arrête là. Le résultat à l'exécution :
id + 1 = 421
TypeError: Cannot read properties of undefined (reading 'toLowerCase')Deux dégâts pour le prix d'un. La première ligne ne plante pas, elle ment : "42" est une chaîne, donc + concatène au lieu d'additionner et affiche 421. Ce genre de bug voyage loin avant qu'on le remarque, parce qu'il ne lève aucune erreur. La seconde ligne, elle, arrête le programme, mais à l'endroit où l'on consomme la donnée, pas à l'endroit où on l'a reçue. Le vrai coupable est en amont, dans un serveur qui n'a pas renvoyé ce qu'on croyait.
La règle qui en découle est simple : il existe une frontière entre le dehors et votre code, et tout ce qui la franchit doit être vérifié une fois, à cet endroit précis.
Le même appel, avec un schéma qui vérifie
Zod s'installe comme n'importe quelle dépendance, et n'entraîne rien avec lui : en 4.6.5, son package.json ne déclare aucune dépendance, ni dependencies ni peerDependencies.
npm install zodUn schéma se lit comme la description d'un objet, champ par champ. On le déclare une fois, puis on lui donne la donnée reçue.
import { z } from "zod";
const Utilisateur = z.object({
id: z.number(),
email: z.email(),
age: z.number(),
});
const reponseDuServeur = '{"id":"42","age":"31"}';
const utilisateur = Utilisateur.parse(JSON.parse(reponseDuServeur));
console.log(utilisateur);Cette fois le programme s'arrête, mais au bon endroit et en disant pourquoi. parse() lève une ZodError qui contient la liste complète des problèmes, pas seulement le premier :
ZodError: [
{
"expected": "number",
"code": "invalid_type",
"path": [
"id"
],
"message": "Invalid input: expected number, received string"
},
{
"expected": "string",
"code": "invalid_type",
"path": [
"email"
],
"message": "Invalid input: expected string, received undefined"
},
{
"expected": "number",
"code": "invalid_type",
"path": [
"age"
],
"message": "Invalid input: expected number, received string"
}
]Trois anomalies remontées en un seul passage, chacune avec son chemin dans l'objet. Une suite de if écrite à la main vous aurait donné la première et se serait tue sur les deux autres, ce qui transforme le débogage en jeu de piste. Notez au passage que le champ email manquant est décrit comme received undefined : pour Zod, une clé absente est une valeur comme une autre, et elle est rejetée.
safeParse, pour que l'erreur ne fasse pas tomber la page
Lever une exception convient à un script, rarement à une interface. La variante safeParse() ne lance rien : elle renvoie un objet avec un champ success, et c'est à vous de décider quoi en faire.
const resultat = Utilisateur.safeParse(JSON.parse('{"id":"42","age":"31"}'));
console.log("success =", resultat.success);
if (!resultat.success) {
console.log("nombre de problemes =", resultat.error.issues.length);
for (const probleme of resultat.error.issues) {
console.log(probleme.path.join(".") + " : " + probleme.message);
}
console.log(JSON.stringify(z.treeifyError(resultat.error), null, 2));
}La boucle sur issues donne exactement ce qu'il faut pour un journal ou un message technique :
success = false
nombre de problemes = 3
id : Invalid input: expected number, received string
email : Invalid input: expected string, received undefined
age : Invalid input: expected number, received stringPour afficher les erreurs sous les bons champs d'un formulaire, ce tableau plat est peu pratique. z.treeifyError() le réorganise en suivant la forme de l'objet, ce qui permet d'aller chercher directement les messages d'un champ précis (sortie condensée ici à une ligne par champ, le contenu est celui du banc) :
{
"errors": [],
"properties": {
"id": { "errors": ["Invalid input: expected number, received string"] },
"email": { "errors": ["Invalid input: expected string, received undefined"] },
"age": { "errors": ["Invalid input: expected number, received string"] }
}
}Le réflexe à prendre : parse() quand une donnée invalide est un bug de votre côté et doit crier fort, safeParse() quand elle vient d'un utilisateur ou d'un service tiers et doit être traitée comme un cas normal.
Le type ne s'écrit plus deux fois : il se déduit du schéma
Jusqu'ici, Zod n'a fait que valider. Le deuxième usage du schéma est celui qui change la façon de travailler : z.infer en extrait le type TypeScript correspondant, sans que vous ayez à le réécrire.
const Utilisateur = z.object({
id: z.number(),
email: z.email(),
age: z.number(),
});
// Le type vient du schéma : une seule source de vérité.
type Utilisateur = z.infer<typeof Utilisateur>;
function afficher(u: Utilisateur) {
console.log(u.email.toLowerCase(), u.age);
}Déclarer une constante et un type du même nom est volontaire et parfaitement légal : TypeScript range les valeurs et les types dans deux espaces séparés, si bien que Utilisateur désigne le schéma dans le code et le type dans une annotation. La preuve que le type déduit est bien le vrai type, et pas un any déguisé : en écrivant u.mail au lieu de u.email, le compilateur répond
error TS2551: Property 'mail' does not exist on type
'{ id: number; email: string; age: number; }'. Did you mean 'email'?Le type affiché dans le message est celui que le schéma décrit, champ par champ. L'intérêt n'est pas d'économiser six lignes : c'est qu'il devient impossible que la vérification et le type divergent. Ajoutez un champ au schéma, il apparaît dans le type ; rendez-le facultatif, il devient facultatif partout. C'est le même raisonnement que pour les génériques en TypeScript, où l'on décrit une forme une seule fois au lieu de la recopier.
Un formulaire ne sait envoyer que du texte
Deuxième frontière, la plus quotidienne. Un champ <input type="number"> a beau s'appeler « number », ce qui arrive côté JavaScript est une chaîne. Un schéma qui attend z.number() refuse donc une saisie pourtant valide : sur le banc, z.object({ age: z.number() }).safeParse({ age: "31" }) donne success = false.
La réponse de Zod s'appelle la coercition : z.coerce.number() convertit avant de vérifier. On en profite pour poser les autres règles au même endroit, messages compris.
const Inscription = z.object({
pseudo: z.string().min(3, "Le pseudo fait au moins 3 caractères."),
age: z.coerce.number().int().min(13).max(120),
newsletter: z.literal("on").optional(),
pays: z.string().default("France"),
});
const ok = Inscription.parse({ pseudo: "sam", age: "31", newsletter: "on" });
console.log("resultat :", ok);
console.log("type de age :", typeof ok.age);Le résultat montre les trois effets en une ligne : la conversion a eu lieu, le champ absent a reçu sa valeur par défaut, et le type réel est bien un nombre.
resultat : { pseudo: 'sam', age: 31, newsletter: 'on', pays: 'France' }
type de age : numberEt avec une saisie fautive, { pseudo: "sa", age: "douze" }, on récupère le message maison pour le pseudo et un message générique pour l'âge :
pseudo : Le pseudo fait au moins 3 caractères.
age : Invalid input: expected number, received NaNCe received NaN mérite qu'on s'y arrête : la coercition applique Number("douze"), qui ne lève pas d'erreur mais produit NaN, et c'est ce NaN que la vérification rejette ensuite. La validation côté navigateur décrite dans l'article sur les formulaires HTML reste utile pour le confort de saisie, mais elle ne remplace jamais cette vérification : rien n'empêche d'envoyer une requête sans passer par la page.
Les clés en trop disparaissent sans prévenir
Voici le comportement qui surprend le plus, et il vaut mieux l'apprendre sur un exemple que sur un incident. Par défaut, un z.object() ne rejette pas les champs qu'il ne connaît pas : il les retire du résultat.
const Profil = z.object({ pseudo: z.string(), age: z.number() });
const recu = { pseudo: "sam", age: 31, role: "admin" };
console.log("sortie :", Profil.parse(recu));sortie : { pseudo: 'sam', age: 31 }Le champ role envoyé par le client n'a pas provoqué d'erreur, il a simplement disparu, et "role" in Profil.parse(recu) vaut false. C'est une protection utile côté serveur, parce qu'un objet nettoyé ne peut pas transporter en douce une élévation de privilège vers votre base. C'est aussi un piège quand vous cherchez pourquoi une donnée que vous croyiez transmise n'arrive jamais.
Deux variantes existent selon ce que vous voulez. .strict() refuse la présence d'un champ inconnu, avec un code d'erreur dédié :
const r = Profil.strict().safeParse(recu);strict success : false
strict message : Unrecognized key: "role" | code : unrecognized_keys.loose() fait l'inverse et conserve tout, y compris ce qui n'est pas décrit : sur le banc, la sortie redevient { pseudo: 'sam', age: 31, role: 'admin' }, à l'identique avec z.looseObject(Profil.shape). Attention si vous croisez .passthrough() dans un exemple en ligne : cette écriture donne le même résultat, mais elle est marquée dépréciée dans les types de Zod 4, à la ligne 494 de node_modules/zod/v4/classic/schemas.d.ts, au profit de .loose(). Les deux méthodes que l'on vient d'utiliser, elles, ne portent qu'une suggestion sans dépréciation (« Consider z.strictObject(A.shape) instead »).
Retenez surtout le comportement par défaut, puisque c'est lui que vous obtiendrez sans rien demander.
La case à cocher qui répond toujours oui
Un piège du même genre, sur la coercition cette fois. Il est tentant d'écrire z.coerce.boolean() pour une case à cocher. Mesuré sur le banc, avec trois valeurs qu'un formulaire peut réellement produire :
coerce.boolean("false") = true
coerce.boolean("") = false
coerce.boolean("on") = trueLa chaîne "false" devient true, ce qui est parfaitement logique et parfaitement traître : la coercition applique Boolean(), et toute chaîne non vide est vraie en JavaScript. Une case décochée dont le serveur reçoit la valeur textuelle "false" serait donc comprise comme acceptée. Pour une case à cocher HTML, la bonne lecture est la valeur réellement envoyée, "on", et l'absence de clé quand elle est décochée :
const CaseHtml = z.object({
cgv: z.literal("on", "Vous devez accepter les CGV."),
});
CaseHtml.safeParse({ cgv: undefined });
// success : false
// message : Vous devez accepter les CGV.La règle générale derrière ce cas : la coercition sert à convertir un format, pas à deviner une intention. Pour un nombre ou une date, elle fait gagner du temps ; pour un booléen venu du web, elle transforme un « non » en « oui ».
Les tutoriels que vous allez croiser sont en Zod 3
Une difficulté pratique attend quiconque cherche de l'aide en ligne : la version 4 a déplacé les validations de format. Là où les articles et les réponses de forums écrivent z.string().email(), la version courante propose z.email(). L'ancienne écriture fonctionne toujours en 4.6.5, avec le même verdict sur les mêmes valeurs, mais la déclaration de types du paquet la marque explicitement comme dépréciée, à la ligne 113 de ce même schemas.d.ts :
/** @deprecated Use `z.email()` instead. */
email(params?: string | core.$ZodCheckEmailParams): this;Concrètement : un tutoriel en z.string().email() reste utilisable, un éditeur adossé au service de langage TypeScript comme VS Code barrera simplement l'appel (les notes de version de TypeScript 4.0 décrivent ce rendu pour l'annotation @deprecated), et z.email() donne exactement le même résultat, message compris (Invalid email address sur une adresse invalide). Le même déplacement vaut pour les autres formats, comme z.uuid() ou z.url(). C'est un bon réflexe à garder pour toute bibliothèque : quand un exemple trouvé en ligne ne compile pas, la première question à se poser est celle de la version, pas celle de votre code. Le sujet rejoint celui de la migration d'un projet JavaScript vers TypeScript, où plusieurs des premières erreurs rencontrées ne viennent pas de la logique du programme mais des réglages du compilateur.
Où poser la validation dans un projet
Reste la question qui décide de l'utilité réelle de tout ceci : à quel endroit appeler le schéma. La réponse tient en une phrase : aux points d'entrée, une seule fois, jamais au milieu du code métier. Trois endroits reviennent toujours.
- après un
fetch, sur le résultat deresponse.json(), avant de le ranger dans une variable ; - à la réception d'un formulaire, côté serveur, même si le navigateur a déjà contrôlé la saisie ;
- à la relecture du stockage du navigateur, parce que ce qui a été écrit par une version précédente de votre code n'a aucune raison d'être encore valide.
Une fois la donnée passée par le schéma, le reste du programme n'a plus à douter : il manipule un type déduit, avec la garantie que la vérification a eu lieu. C'est exactement la discipline décrite dans l'article sur unknown et any, où l'on accepte une donnée comme inconnue avant de la restreindre, sauf qu'ici la restriction est écrite une fois et produit le type.
Il n'y a pas grand-chose de plus à apprendre pour commencer : un schéma, safeParse, z.infer, et les trois pièges mesurés plus haut : les clés en trop qui disparaissent, la case à cocher qui répond toujours oui, la syntaxe des tutoriels restés en Zod 3. Le reste du catalogue de Zod, les tableaux, les unions, les transformations, s'ajoutera le jour où un cas précis le réclamera.