Pourquoi Zod change la donne en validation TypeScript ?
TypeScript protege votre code a la compilation, mais les types disparaissent a l'execution. Quand votre application recoit des donnees d'une API, d'un formulaire ou d'une variable d'environnement, TypeScript ne peut plus rien garantir. C'est la que Zod intervient : il valide les donnees a l'execution tout en inferrant automatiquement les types TypeScript correspondants.
Pourquoi la validation runtime est indispensable
TypeScript compile en JavaScript -- les types s'effacent completement a l'execution.
- API responses : une API externe peut changer son format sans prevenir
- Formulaires : les donnees utilisateur sont imprevisibles par nature
- Variables d'env : process.env retourne string | undefined, jamais le type attendu
- localStorage / cookies : donnees serialisees sans garantie de structure
1// Le probleme : TypeScript fait confiance aveuglément2interface User {3 id: number;4 name: string;5 email: string;6}7
8// TypeScript ne verifie PAS que la reponse est conforme9const response = await fetch('/api/users/1');10const user: User = await response.json(); // Dangereux !11// Si l'API renvoie { id: "abc", nom: "Jean" }, aucune erreur12// Le crash arrive plus tard, dans un endroit inattenduCe qui se passe sous le capot : le compilateur efface les types
TypeScript compile en JavaScript pur. Le compilateur tsc supprime TOUS les types, interfaces et generics. Il ne reste aucune verification a l'execution.
const user: User = datadevient simplementconst user = dataen JavaScript- Les
interface,typeetas Usersont des instructions pour le compilateur, pas pour le runtime - Le navigateur et Node.js executent du JavaScript, pas du TypeScript -- aucune verification de type a l'execution
- Les bugs lies aux types incorrects sont silencieux : les donnees se propagent dans l'application avant de provoquer un crash
1// AVANT compilation (TypeScript)2interface User {3 id: number;4 email: string;5}6
7const response = await fetch('/api/users/1');8const user: User = await response.json();9
10console.log(user.id + 1); // On attend 211console.log(user.email.toUpperCase()); // On attend "JEAN@EXAMPLE.COM"12
13// -----------------------------------------------------------14// APRES compilation (JavaScript genere par tsc)15// Les types ont COMPLETEMENT disparu :16
17const response = await fetch('/api/users/1');18const user = await response.json(); // Plus aucun type !19
20console.log(user.id + 1);21console.log(user.email.toUpperCase());22
23// -----------------------------------------------------------24// Ce qui se passe REELLEMENT quand l'API renvoie des types inattendus :25// L'API renvoie : { id: "abc", email: 42 }26
27user.id + 1;28// Resultat : "abc1" (concatenation string au lieu d'addition)29// Pas d'erreur ! Le bug se propage silencieusement.30
31user.email.toUpperCase();32// TypeError: user.email.toUpperCase is not a function33// Car email est un number (42), pas un string.34// Le crash arrive ICI, loin de la source du probleme.35
36// TypeScript n'a RIEN pu faire : les types avaient deja ete effaces.37// Sans validation runtime, vous decouvrez ces bugs en production.La solution Zod : un schema = un type
Avec Zod, vous definissez un schema de validation ET TypeScript infere le type automatiquement. Une seule source de verite.
- TypeScript-first : types inferes automatiquement via z.infer
- Zero dependances : ~17 KB gzipped, aucune librairie tierce
- Ecosysteme riche : integrations natives avec tRPC, React Hook Form, shadcn/ui
- API composable : schemas reutilisables, extensibles, transformables
1import { z } from 'zod';2
3// 1. Definir le schema4const UserSchema = z.object({5 id: z.number(),6 name: z.string().min(1),7 email: z.string().email(),8});9
10// 2. TypeScript infere le type automatiquement11type User = z.infer<typeof UserSchema>;12// => { id: number; name: string; email: string }13
14// 3. Valider les donnees a l'execution15const response = await fetch('/api/users/1');16const data = await response.json();17const user = UserSchema.parse(data); // Valide ET type !18// Si les donnees sont invalides -> ZodError avec detailsRetour d'experience -- Scanorr
J'ai adopte Zod dans mon SaaS Scanorr principalement pour deux raisons : la validation des objets API entrants et la validation des formulaires. Ce qui m'a convaincu, c'est la simplicite d'utilisation -- definir un schema et obtenir a la fois la validation runtime et le type TypeScript sans duplication de code. Sur un SaaS ou la fiabilite des donnees est critique, cette approche a elimine une categorie entiere de bugs lies aux donnees mal formees.
Comment créer vos premiers schémas de validation ?
Zod fournit des schemas pour tous les types primitifs TypeScript, chacun avec des methodes de validation chainables. C'est la base sur laquelle tout le reste est construit.
Types primitifs
Chaque type TypeScript a son equivalent Zod. Le schema valide la donnee et infere le type correspondant.
1import { z } from 'zod';2
3// Types de base4const stringSchema = z.string(); // string5const numberSchema = z.number(); // number6const boolSchema = z.boolean(); // boolean7const dateSchema = z.date(); // Date8const bigintSchema = z.bigint(); // bigint9
10// Types speciaux11const undefinedSchema = z.undefined(); // undefined12const nullSchema = z.null(); // null13const voidSchema = z.void(); // void14const anySchema = z.any(); // any (a eviter)15const unknownSchema = z.unknown(); // unknown (prefere a any)16const neverSchema = z.never(); // never17
18// Litteraux19const tealSchema = z.literal('teal'); // 'teal'20const fortyTwoSchema = z.literal(42); // 4221const trueSchema = z.literal(true); // trueValidateurs de chaines
z.string() accepte des dizaines de methodes chainables pour valider le format, la longueur et le contenu.
1const emailSchema = z.string().email('Email invalide');2const urlSchema = z.string().url('URL invalide');3const uuidSchema = z.string().uuid('UUID invalide');4
5// Longueur6const usernameSchema = z.string()7 .min(3, 'Minimum 3 caracteres')8 .max(20, 'Maximum 20 caracteres');9
10// Regex11const slugSchema = z.string()12 .regex(/^[a-z0-9-]+$/, 'Slug invalide : lettres minuscules, chiffres et tirets uniquement');13
14// Transformations de chaines15const normalizedEmail = z.string()16 .email()17 .trim()18 .toLowerCase();19
20// Formats courants21const ipSchema = z.string().ip(); // IPv4 ou IPv622const cidrSchema = z.string().cidr(); // Notation CIDR23const emojiSchema = z.string().emoji(); // Emojis uniquement24const datetimeSchema = z.string().datetime(); // ISO 860125const nanoidSchema = z.string().nanoid(); // NanoidValidateurs de nombres
z.number() offre des contraintes numeriques precises pour valider intervalles, entiers et proprietes mathematiques.
1// Contraintes numeriques2const ageSchema = z.number()3 .int('L\'age doit etre un entier')4 .min(0, 'L\'age doit etre positif')5 .max(150, 'Age invalide');6
7const priceSchema = z.number()8 .positive('Le prix doit etre positif')9 .multipleOf(0.01, 'Maximum 2 decimales');10
11const temperatureSchema = z.number()12 .gte(-273.15, 'Impossible : en dessous du zero absolu')13 .finite();14
15// Raccourcis utiles16z.number().positive(); // > 017z.number().nonnegative(); // >= 018z.number().negative(); // < 019z.number().nonpositive(); // <= 020z.number().int(); // entier21z.number().finite(); // pas Infinity22z.number().safe(); // dans Number.MIN_SAFE_INTEGER..MAX_SAFE_INTEGEROptionnel, nullable et valeurs par defaut
Trois methodes pour gerer l'absence de valeur, chacune avec un comportement distinct.
1const schema = z.object({2 // Requis : doit etre present et non-null3 name: z.string(),4
5 // Optionnel : string | undefined6 bio: z.string().optional(),7
8 // Nullable : string | null9 avatar: z.string().url().nullable(),10
11 // Nullish : string | null | undefined12 nickname: z.string().nullish(),13
14 // Valeur par defaut : si absent, utilise la valeur fournie15 role: z.enum(['admin', 'user', 'viewer']).default('viewer'),16
17 // Optionnel avec defaut : toujours present dans le resultat18 notifications: z.boolean().default(true),19});20
21type Profile = z.infer<typeof schema>;22// {23// name: string;24// bio?: string | undefined;25// avatar: string | null;26// nickname?: string | null | undefined;27// role: 'admin' | 'user' | 'viewer'; // jamais undefined grace a default28// notifications: boolean; // jamais undefined grace a default29// }Quelle méthode choisir : parse ou safeParse ?
Zod offre deux strategies de parsing : parse() qui lance une exception en cas d'erreur, et safeParse() qui retourne un objet resultat. Le choix entre les deux depend du contexte d'utilisation.
parse() -- Fail fast
Lance une ZodError si les donnees sont invalides. Ideal quand les donnees doivent absolument etre valides.
- Retourne directement la donnee typee
- Arrete l'execution immediatement si invalide
- Necessite un try/catch pour gerer l'erreur
1import { z } from 'zod';2
3const UserSchema = z.object({4 name: z.string().min(1),5 email: z.string().email(),6 age: z.number().int().positive(),7});8
9// parse() lance une exception si invalide10try {11 const user = UserSchema.parse({12 name: 'Jean Dupont',13 email: 'jean@example.com',14 age: 30,15 });16 // user est type : { name: string; email: string; age: number }17 console.log(user.name); // 'Jean Dupont'18} catch (error) {19 if (error instanceof z.ZodError) {20 console.error(error.issues);21 }22}safeParse() -- Gestion gracieuse
Retourne un objet { success, data } ou { success, error } sans lancer d'exception. Recommande pour les formulaires et API.
- Pas besoin de try/catch
- Acces structure aux erreurs par champ
- Plus performant que try/catch en cas d'echec frequent
1const result = UserSchema.safeParse({2 name: '',3 email: 'pas-un-email',4 age: -5,5});6
7if (result.success) {8 // result.data est type { name: string; email: string; age: number }9 console.log(result.data);10} else {11 // result.error est une ZodError12 console.log(result.error.issues);13 // [14 // { code: 'too_small', minimum: 1, path: ['name'], message: '...' },15 // { code: 'invalid_string', path: ['email'], message: 'Invalid email' },16 // { code: 'too_small', minimum: 0, path: ['age'], message: '...' }17 // ]18}Formater les erreurs
ZodError propose plusieurs methodes pour transformer les erreurs en formats exploitables.
1const result = UserSchema.safeParse({ name: '', email: 'bad', age: -1 });2
3if (!result.success) {4 // flatten() -- ideal pour les formulaires5 const flat = result.error.flatten();6 // {7 // formErrors: [], // erreurs globales8 // fieldErrors: {9 // name: ['String must contain at least 1 character(s)'],10 // email: ['Invalid email'],11 // age: ['Number must be greater than 0'],12 // }13 // }14
15 // format() -- structure imbriquee16 const formatted = result.error.format();17 // {18 // name: { _errors: ['String must contain at least 1 character(s)'] },19 // email: { _errors: ['Invalid email'] },20 // age: { _errors: ['Number must be greater than 0'] },21 // _errors: []22 // }23
24 // issues -- acces brut a chaque erreur25 result.error.issues.forEach((issue) => {26 console.log(`${issue.path.join('.')}: ${issue.message}`);27 });28}1// Messages d'erreur personnalises2const ContactSchema = z.object({3 name: z.string({4 required_error: 'Le nom est requis',5 invalid_type_error: 'Le nom doit etre une chaine',6 }).min(2, 'Le nom doit contenir au moins 2 caracteres'),7
8 email: z.string()9 .min(1, 'L\'email est requis')10 .email('Format d\'email invalide'),11
12 phone: z.string()13 .regex(/^\+?[0-9]{10,14}$/, 'Numero de telephone invalide')14 .optional(),15
16 message: z.string()17 .min(10, 'Le message doit contenir au moins 10 caracteres')18 .max(1000, 'Le message ne peut pas depasser 1000 caracteres'),19});20
21// Chaque champ a des messages clairs et en francais22const result = ContactSchema.safeParse({23 name: 'J',24 email: 'pas-valide',25 message: 'Court',26});27// Erreurs :28// name: 'Le nom doit contenir au moins 2 caracteres'29// email: 'Format d\'email invalide'30// message: 'Le message doit contenir au moins 10 caracteres'Erreurs explicites en console -- le vrai confort en dev
Contrairement aux erreurs generiques JavaScript, Zod structure chaque erreur avec le chemin exact, le type attendu vs recu, et votre message personnalise. En dev ou au build, on comprend immediatement d'ou vient le probleme.
- path : le chemin exact du champ en erreur (ex: ['user', 'address', 'zipCode'])
- code : le type d'erreur (invalid_type, too_small, invalid_string...)
- expected / received : ce qui etait attendu vs ce qui a ete recu
- message : votre message personnalise, configurable par champ ou globalement
1// SANS ZOD : erreur generique, difficile a tracer2const user = apiResponse.data;3console.log(user.profile.email.toUpperCase());4// TypeError: Cannot read properties of undefined (reading 'toUpperCase')5// D'ou vient le probleme ? profile est null ? email est undefined ? Aucune idee.6
7// AVEC ZOD : erreur structuree, on comprend tout immediatement8const result = UserSchema.safeParse(apiResponse.data);9if (!result.success) {10 console.error(result.error.issues);11 // [12 // {13 // code: 'invalid_type',14 // expected: 'string',15 // received: 'number',16 // path: ['profile', 'email'],17 // message: 'Expected string, received number'18 // },19 // {20 // code: 'too_small',21 // minimum: 1,22 // type: 'string',23 // inclusive: true,24 // path: ['name'],25 // message: 'Le nom est requis'26 // }27 // ]28 // On sait EXACTEMENT : quel champ, quelle regle, quel type attendu vs recu29}30
31// Personnaliser TOUS les messages globalement avec setErrorMap32import { z } from 'zod';33
34const customErrorMap: z.ZodErrorMap = (issue, ctx) => {35 // Personnaliser les erreurs de type36 if (issue.code === z.ZodIssueCode.invalid_type) {37 return { message: `Attendu ${issue.expected}, recu ${issue.received}` };38 }39 // Personnaliser les erreurs de longueur40 if (issue.code === z.ZodIssueCode.too_small) {41 return { message: `Minimum ${issue.minimum} caracteres requis` };42 }43 return { message: ctx.defaultError };44};45
46z.setErrorMap(customErrorMap);47// Maintenant TOUTES les erreurs Zod utilisent vos messages personnalisesDouble filet : erreurs UI + console
Couple avec React Hook Form, Zod offre un double avantage : l'utilisateur voit les erreurs dans l'interface, et le developpeur voit les details techniques en console. Le deuxieme argument de handleSubmit() est la pour ca.
- Cote UI : zodResolver transforme les erreurs Zod en formState.errors pour l'affichage
- Cote console : handleSubmit(onValid, onInvalid) -- le 2e argument recoit les erreurs structurees
- En dev : chaque erreur inclut message, type et ref vers l'element DOM concerne
1// Pattern "double filet" : erreurs UI + console2import { useForm } from 'react-hook-form';3import { zodResolver } from '@hookform/resolvers/zod';4import { z } from 'zod';5
6const LoginSchema = z.object({7 email: z.string().min(1, 'L\'email est requis').email('Email invalide'),8 password: z.string().min(8, 'Minimum 8 caracteres'),9});10
11type LoginForm = z.infer<typeof LoginSchema>;12
13export function LoginFormComponent() {14 const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({15 resolver: zodResolver(LoginSchema),16 });17
18 // Callback quand le formulaire est VALIDE19 function onValid(data: LoginForm) {20 console.log('Donnees validees :', data);21 }22
23 // Callback quand le formulaire est INVALIDE24 // -> handleSubmit accepte un 2e argument pour capturer les erreurs25 function onInvalid(errors: Record<string, unknown>) {26 console.error('Erreurs de validation :', errors);27 // Console affiche :28 // {29 // email: { message: 'Email invalide', type: 'invalid_string', ref: input#email },30 // password: { message: 'Minimum 8 caracteres', type: 'too_small', ref: input#password }31 // }32 // -> On voit le message, le type d'erreur Zod, et la reference DOM33 }34
35 return (36 <form onSubmit={handleSubmit(onValid, onInvalid)}>37 <div>38 <input {...register('email')} placeholder="Email" />39 {/* L'utilisateur voit l'erreur dans l'UI */}40 {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}41 </div>42
43 <div>44 <input {...register('password')} type="password" placeholder="Mot de passe" />45 {errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}46 </div>47
48 <button type="submit">Se connecter</button>49 </form>50 );51 // Resultat : l'utilisateur voit "Email invalide" dans le formulaire52 // ET le developpeur voit la structure complete dans la console du navigateur53}Quand utiliser parse vs safeParse ?
parse() est adapte pour :
- Validation au demarrage (env vars)
- Donnees internes de confiance
- Scripts et migrations
safeParse() est adapte pour :
- Formulaires utilisateur
- API endpoints (requetes entrantes)
- Reponses d'API externes
Comment valider des structures de données imbriquées ?
Les schemas primitifs sont la base, mais les applications reelles manipulent des objets imbriques, des tableaux, des unions et des enumerations. Zod couvre tous ces cas avec une API coherente.
z.object() -- Le schema le plus utilise
Definit la structure d'un objet avec des proprietes typees. Chaque propriete est elle-meme un schema Zod.
1import { z } from 'zod';2
3// Schema d'un utilisateur complet4const UserSchema = z.object({5 id: z.string().uuid(),6 name: z.string().min(1),7 email: z.string().email(),8 age: z.number().int().positive().optional(),9 address: z.object({10 street: z.string(),11 city: z.string(),12 zipCode: z.string().regex(/^\d{5}$/),13 country: z.string().default('FR'),14 }),15 tags: z.array(z.string()).default([]),16 createdAt: z.date(),17});18
19type User = z.infer<typeof UserSchema>;20// {21// id: string;22// name: string;23// email: string;24// age?: number | undefined;25// address: { street: string; city: string; zipCode: string; country: string };26// tags: string[];27// createdAt: Date;28// }z.array() -- Tableaux types
Valide chaque element d'un tableau avec le schema fourni. Methodes pour controler la taille.
1// Tableau de strings2const tagsSchema = z.array(z.string());3
4// Contraintes sur le tableau5const itemsSchema = z.array(z.number())6 .nonempty('Au moins un element requis') // [number, ...number[]]7 .min(2, 'Minimum 2 elements')8 .max(100, 'Maximum 100 elements');9
10// Tableau d'objets11const OrderSchema = z.object({12 id: z.string(),13 items: z.array(z.object({14 productId: z.string(),15 quantity: z.number().int().positive(),16 price: z.number().positive(),17 })).nonempty('La commande doit contenir au moins un article'),18 total: z.number().positive(),19});20
21// Tuples -- tableau avec types fixes par position22const CoordinatesSchema = z.tuple([23 z.number(), // latitude24 z.number(), // longitude25]);26// type : [number, number]27
28// Tuple avec rest element29const LogEntrySchema = z.tuple([30 z.date(), // timestamp31 z.string(), // level32]).rest(z.string()); // ...messages33// type : [Date, string, ...string[]]z.enum() et z.nativeEnum()
Deux approches pour valider des valeurs parmi un ensemble fini.
1// z.enum() -- enumeration Zod (recommande)2const RoleSchema = z.enum(['admin', 'editor', 'viewer']);3type Role = z.infer<typeof RoleSchema>; // 'admin' | 'editor' | 'viewer'4
5// Acces aux valeurs6RoleSchema.options; // ['admin', 'editor', 'viewer']7RoleSchema.enum; // { admin: 'admin', editor: 'editor', viewer: 'viewer' }8
9// z.nativeEnum() -- enum TypeScript existante10enum Status {11 Active = 'active',12 Inactive = 'inactive',13 Pending = 'pending',14}15const StatusSchema = z.nativeEnum(Status);16type StatusType = z.infer<typeof StatusSchema>; // Status17
18// Record -- objet avec cles dynamiques19const SettingsSchema = z.record(z.string(), z.boolean());20// type : Record<string, boolean>21// Valide : { darkMode: true, notifications: false }22
23// Record avec cle contrainte24const ScoresSchema = z.record(25 z.enum(['math', 'french', 'english']),26 z.number().min(0).max(20)27);28// type : Partial<Record<'math' | 'french' | 'english', number>>z.union() et z.discriminatedUnion()
Quand une donnee peut avoir plusieurs formes, les unions permettent de definir toutes les variantes acceptees.
1// Union simple -- essaie chaque schema dans l'ordre2const StringOrNumberSchema = z.union([z.string(), z.number()]);3// Equivalent raccourci :4const shorthand = z.string().or(z.number());5
6// Discriminated union -- plus performante, utilise un champ discriminant7const EventSchema = z.discriminatedUnion('type', [8 z.object({9 type: z.literal('click'),10 x: z.number(),11 y: z.number(),12 target: z.string(),13 }),14 z.object({15 type: z.literal('keypress'),16 key: z.string(),17 modifiers: z.array(z.enum(['ctrl', 'shift', 'alt', 'meta'])),18 }),19 z.object({20 type: z.literal('scroll'),21 deltaX: z.number(),22 deltaY: z.number(),23 }),24]);25
26type Event = z.infer<typeof EventSchema>;27// { type: 'click'; x: number; y: number; target: string }28// | { type: 'keypress'; key: string; modifiers: ('ctrl' | 'shift' | 'alt' | 'meta')[] }29// | { type: 'scroll'; deltaX: number; deltaY: number }30
31// Avantage : Zod regarde d'abord le champ 'type'32// pour savoir quel schema appliquer -> beaucoup plus rapide1// Intersection -- combiner deux schemas2const WithTimestamps = z.object({3 createdAt: z.date(),4 updatedAt: z.date(),5});6
7const PostSchema = z.object({8 title: z.string(),9 content: z.string(),10});11
12const PostWithTimestamps = z.intersection(PostSchema, WithTimestamps);13// Equivalent raccourci :14const shorthand = PostSchema.and(WithTimestamps);15
16type Post = z.infer<typeof PostWithTimestamps>;17// { title: string; content: string; createdAt: Date; updatedAt: Date }Comment réutiliser vos schémas sans duplication ?
Un des atouts majeurs de Zod est la composabilite des schemas. Au lieu de dupliquer des definitions, vous derivez de nouveaux schemas a partir d'un schema de base -- exactement comme les utility types TypeScript (Pick, Omit, Partial).
Le pattern de base : deriver pour ne pas dupliquer
Definir un schema source, puis en deriver des variantes pour chaque contexte (creation, mise a jour, affichage public).
- .extend() : ajouter des proprietes
- .pick() : selectionner des proprietes
- .omit() : exclure des proprietes
- .partial() : tout rendre optionnel
- .required() : tout rendre requis
1import { z } from 'zod';2
3// Schema de base -- la source de verite4const UserSchema = z.object({5 id: z.string().uuid(),6 name: z.string().min(1),7 email: z.string().email(),8 password: z.string().min(8),9 role: z.enum(['admin', 'editor', 'viewer']),10 avatar: z.string().url().nullable(),11 createdAt: z.date(),12 updatedAt: z.date(),13});14
15// Creation : sans id ni timestamps (generes cote serveur)16const CreateUserSchema = UserSchema.omit({17 id: true,18 createdAt: true,19 updatedAt: true,20});21type CreateUser = z.infer<typeof CreateUserSchema>;22// { name: string; email: string; password: string; role: ...; avatar: string | null }23
24// Mise a jour partielle : tout optionnel sauf l'id25const UpdateUserSchema = UserSchema.pick({26 name: true,27 email: true,28 avatar: true,29 role: true,30}).partial();31type UpdateUser = z.infer<typeof UpdateUserSchema>;32// { name?: string; email?: string; avatar?: string | null; role?: ... }33
34// Vue publique : sans password ni timestamps35const PublicUserSchema = UserSchema.omit({36 password: true,37 updatedAt: true,38});39type PublicUser = z.infer<typeof PublicUserSchema>;40// { id: string; name: string; email: string; role: ...; avatar: ...; createdAt: Date }extend() et merge()
Ajouter des proprietes a un schema existant ou fusionner deux schemas.
1// extend() -- ajouter des proprietes2const BaseArticleSchema = z.object({3 title: z.string().min(1),4 content: z.string(),5});6
7const PublishedArticleSchema = BaseArticleSchema.extend({8 slug: z.string().regex(/^[a-z0-9-]+$/),9 publishedAt: z.date(),10 author: z.object({11 name: z.string(),12 avatar: z.string().url().optional(),13 }),14});15
16// merge() -- fusionner deux schemas objets17const TimestampSchema = z.object({18 createdAt: z.date(),19 updatedAt: z.date(),20});21
22const SoftDeleteSchema = z.object({23 deletedAt: z.date().nullable(),24 isActive: z.boolean(),25});26
27// Fusion de 3 schemas28const FullArticleSchema = PublishedArticleSchema29 .merge(TimestampSchema)30 .merge(SoftDeleteSchema);31
32type FullArticle = z.infer<typeof FullArticleSchema>;33// Combine toutes les proprietes des 3 schemasGestion des proprietes inconnues
Par defaut, Zod supprime les proprietes non definies dans le schema. Trois methodes pour controler ce comportement.
1const ProductSchema = z.object({2 name: z.string(),3 price: z.number(),4});5
6const input = { name: 'Widget', price: 9.99, discount: 0.1, sku: 'W-001' };7
8// Comportement par defaut : strip (supprime les proprietes inconnues)9ProductSchema.parse(input);10// { name: 'Widget', price: 9.99 } -- discount et sku supprimes11
12// strict() -- rejette les proprietes inconnues13ProductSchema.strict().parse(input);14// ZodError: Unrecognized key(s) in object: 'discount', 'sku'15
16// passthrough() -- conserve les proprietes inconnues17ProductSchema.passthrough().parse(input);18// { name: 'Widget', price: 9.99, discount: 0.1, sku: 'W-001' }19
20// catchall() -- valide les proprietes inconnues avec un schema21ProductSchema.catchall(z.string()).parse({22 name: 'Widget',23 price: 9.99,24 note: 'fragile', // OK : string25});1// Pattern reel : schemas partages client/serveur2// schemas/user.ts -- fichier partage3
4import { z } from 'zod';5
6export const UserBaseSchema = z.object({7 name: z.string().min(1, 'Le nom est requis'),8 email: z.string().email('Email invalide'),9 role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),10});11
12// Pour le formulaire d'inscription (client)13export const SignupFormSchema = UserBaseSchema.extend({14 password: z.string().min(8, 'Minimum 8 caracteres'),15 confirmPassword: z.string(),16}).refine((data) => data.password === data.confirmPassword, {17 message: 'Les mots de passe ne correspondent pas',18 path: ['confirmPassword'],19});20
21// Pour la reponse API (serveur -> client)22export const UserResponseSchema = UserBaseSchema.extend({23 id: z.string().uuid(),24 createdAt: z.string().datetime(),25});26
27// Pour la mise a jour (client -> serveur)28export const UpdateProfileSchema = UserBaseSchema29 .pick({ name: true, email: true })30 .partial();31
32// Tous les types inferes automatiquement33export type SignupForm = z.infer<typeof SignupFormSchema>;34export type UserResponse = z.infer<typeof UserResponseSchema>;35export type UpdateProfile = z.infer<typeof UpdateProfileSchema>;Comment ajouter une logique personnalisée à vos validations ?
Au-dela de la validation, Zod peut transformer les donnees pendant le parsing et ajouter des regles de validation custom. C'est ce qui en fait bien plus qu'un simple validateur.
.transform() -- Modifier la sortie
Applique une fonction de transformation apres la validation. Le type de sortie peut differer du type d'entree.
1import { z } from 'zod';2
3// Transformer une string en nombre4const NumericStringSchema = z.string()5 .transform((val) => parseInt(val, 10));6// Input : string -> Output : number7
8NumericStringSchema.parse('42'); // 42 (number)9NumericStringSchema.parse('abc'); // NaN (attention !)10
11// Transformer et valider le resultat avec pipe12const SafeNumericSchema = z.string()13 .transform((val) => parseInt(val, 10))14 .pipe(z.number().int().positive());15// Input : string -> parse int -> verifie que c'est un entier positif16
17SafeNumericSchema.parse('42'); // 4218SafeNumericSchema.parse('abc'); // ZodError (NaN n'est pas un entier positif)19SafeNumericSchema.parse('-5'); // ZodError (pas positif)1// z.coerce -- coercion automatique (plus simple que transform)2const CoercedNumber = z.coerce.number();3CoercedNumber.parse('42'); // 424CoercedNumber.parse(true); // 15
6const CoercedDate = z.coerce.date();7CoercedDate.parse('2024-01-15'); // Date object8CoercedDate.parse(1705276800000); // Date object (timestamp)9
10const CoercedBoolean = z.coerce.boolean();11CoercedBoolean.parse('true'); // true12CoercedBoolean.parse(''); // false13CoercedBoolean.parse(0); // false14CoercedBoolean.parse(1); // true15
16// Cas d'usage frequent : query parameters (toujours des strings)17const PaginationSchema = z.object({18 page: z.coerce.number().int().positive().default(1),19 limit: z.coerce.number().int().min(1).max(100).default(20),20 sort: z.enum(['asc', 'desc']).default('desc'),21});22
23// URL : ?page=2&limit=50&sort=asc24PaginationSchema.parse({ page: '2', limit: '50', sort: 'asc' });25// { page: 2, limit: 50, sort: 'asc' } -- strings converties en nombresTransforms avances
Les transforms peuvent normaliser, formatter ou enrichir les donnees validees.
1// Normaliser un slug2const SlugSchema = z.string()3 .trim()4 .toLowerCase()5 .transform((val) => val.replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''));6
7SlugSchema.parse(' Mon Article ! '); // 'mon-article-'8
9// Transformer un objet complet10const CreateOrderSchema = z.object({11 items: z.array(z.object({12 productId: z.string(),13 quantity: z.number().int().positive(),14 unitPrice: z.number().positive(),15 })),16 couponCode: z.string().optional(),17}).transform((order) => ({18 ...order,19 itemCount: order.items.length,20 subtotal: order.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0),21 hasCoupon: !!order.couponCode,22}));23
24type CreateOrderInput = z.input<typeof CreateOrderSchema>;25// { items: [...]; couponCode?: string }26
27type CreateOrderOutput = z.output<typeof CreateOrderSchema>;28// { items: [...]; couponCode?: string; itemCount: number; subtotal: number; hasCoupon: boolean }29
30// z.input vs z.infer (= z.output)31// z.input = type AVANT transform32// z.infer = type APRES transform (= z.output).refine() -- Validation custom
Ajouter des regles de validation qui ne peuvent pas etre exprimees avec les methodes internes de Zod.
1// Refine simple avec predicat2const NonEmptyArraySchema = z.array(z.string())3 .refine((arr) => arr.length > 0, {4 message: 'Le tableau ne doit pas etre vide',5 });6
7// Validation cross-field : mots de passe8const PasswordFormSchema = z.object({9 password: z.string().min(8),10 confirmPassword: z.string(),11}).refine((data) => data.password === data.confirmPassword, {12 message: 'Les mots de passe ne correspondent pas',13 path: ['confirmPassword'], // Erreur attachee au champ confirmPassword14});15
16// Validation cross-field : dates coherentes17const DateRangeSchema = z.object({18 startDate: z.coerce.date(),19 endDate: z.coerce.date(),20}).refine((data) => data.endDate > data.startDate, {21 message: 'La date de fin doit etre apres la date de debut',22 path: ['endDate'],23});24
25// Refine async -- validation avec appel externe26const UniqueEmailSchema = z.string().email()27 .refine(async (email) => {28 const exists = await checkEmailExists(email);29 return !exists;30 }, {31 message: 'Cet email est deja utilise',32 });33
34// Utiliser avec parseAsync35await UniqueEmailSchema.parseAsync('jean@example.com');.superRefine() -- Validation avancee
Contrairement a refine(), superRefine() donne acces au contexte complet et permet d'ajouter plusieurs erreurs.
1// superRefine -- plusieurs erreurs a la fois2const StrongPasswordSchema = z.string().superRefine((val, ctx) => {3 if (val.length < 8) {4 ctx.addIssue({5 code: z.ZodIssueCode.too_small,6 minimum: 8,7 type: 'string',8 inclusive: true,9 message: 'Le mot de passe doit contenir au moins 8 caracteres',10 });11 }12 if (!/[A-Z]/.test(val)) {13 ctx.addIssue({14 code: z.ZodIssueCode.custom,15 message: 'Le mot de passe doit contenir au moins une majuscule',16 });17 }18 if (!/[0-9]/.test(val)) {19 ctx.addIssue({20 code: z.ZodIssueCode.custom,21 message: 'Le mot de passe doit contenir au moins un chiffre',22 });23 }24 if (!/[^a-zA-Z0-9]/.test(val)) {25 ctx.addIssue({26 code: z.ZodIssueCode.custom,27 message: 'Le mot de passe doit contenir au moins un caractere special',28 });29 }30});31
32// Toutes les regles non respectees sont retournees d'un coup33const result = StrongPasswordSchema.safeParse('abc');34// 4 erreurs simultanees au lieu d'une seule1// pipe() -- chainer des schemas2// Utile pour separer validation et transformation3const DateStringSchema = z.string()4 .datetime() // 1. Valide que c'est un ISO datetime5 .pipe(z.coerce.date()) // 2. Convertit en Date6 .pipe(z.date().min( // 3. Verifie que la date est dans le futur7 new Date(),8 'La date doit etre dans le futur'9 ));10
11// Chaque etape du pipe est independante et reutilisable12const ISODateTimeSchema = z.string().datetime();13const FutureDateSchema = z.date().min(new Date());14
15// Equivalent compose16const ComposedSchema = ISODateTimeSchema17 .pipe(z.coerce.date())18 .pipe(FutureDateSchema);Zod impacte-t-il les performances de votre app ?
Zod n'est pas parfait. Il est important de comprendre ses forces et ses limites pour faire un choix eclaire. Cette section compare honnement Zod a ses alternatives sur les criteres qui comptent : performance, bundle size, DX et ecosysteme.
Zod v4 -- Les gains de performance
Zod v4 a apporte des ameliorations significatives par rapport a v3, mais reste derriere certains concurrents specialises.
Ameliorations v4
- 14x plus rapide sur le parsing de strings
- 7x plus rapide sur les arrays
- 6.5x plus rapide sur les objets
- Core 57% plus petit
- .toJSONSchema() natif
Limites qui persistent
- ArkType reste 3-4x plus rapide
- Bundle ~17 KB vs Valibot ~1.37 KB
- Immutable par defaut (copies memoire)
- Impact sur le TypeScript compiler
1// Taille de bundle comparee (gzipped)2// Zod : ~17 KB3// Zod Mini : ~1.9 KB (sous-package @zod/mini)4// Valibot : ~1.37 KB (tree-shakeable)5// ArkType : ~5 KB (variable selon usage)6// Yup : ~12 KB7// Joi : ~30 KB (non concu pour le frontend)8
9// Benchmarks approximatifs (operations/sec, objets simples)10// ArkType : ~76M ops/sec11// Valibot : ~25M ops/sec12// Zod v4 : ~6.7M ops/sec13// Zod v3 : < 1M ops/sec14// Yup : ~800K ops/sec15
16// Pour la majorite des applications, Zod est largement suffisant.17// La difference ne se ressent qu'en haute charge18// (ex: valider 500K+ objets en batch).- TypeScript-first, z.infer natif
- Zero dependances
- Ecosysteme riche (tRPC, RHF, shadcn)
- v4 : 14x plus rapide sur les strings
- Bundle ~17 KB gzipped
- Plus lent que ArkType (3-4x)
- Overhead memoire (immutable)
- TS compiler overhead
- •Applications TypeScript modernes
- •Stack tRPC / Next.js
- •Formulaires avec React Hook Form
- ~1.37 KB gzipped (90% plus leger)
- Tree-shakeable par design
- API pipe() composable
- Standard Schema compatible
- Ecosysteme plus restreint
- Moins de tutoriels et exemples
- API differente de Zod
- •SPAs ou le bundle size est critique
- •Edge functions et workers
- •Projets performance-first
- 3-4x plus rapide que Zod
- Syntaxe concise et expressive
- Inference de types avancee
- API non conventionnelle
- Ecosysteme naissant
- Documentation en evolution
- •Hot paths performance-critiques
- •APIs a haute charge
- •Projets ou la vitesse prime
- API intuitive et fluide
- Bonne integration Formik
- Mature et stable
- Pas TypeScript-first
- Inference de types limitee
- Moins maintenu recemment
- •Projets Formik existants
- •Formulaires simples
- •Migration progressive
- Tres complet en fonctionnalites
- Documentation exhaustive
- Large communaute backend
- Pas de support TypeScript natif
- Lourd pour le frontend
- Non concu pour le navigateur
- •APIs Node.js pures
- •Projets backend existants
- •Validation server-only
- Types bidirectionnels (encode/decode)
- Approche fonctionnelle pure
- Composition avancee
- Courbe apprentissage fp-ts
- Tres verbeux
- Ecosysteme de niche
- •Projets fp-ts existants
- •Serialisation/deserialisation
- •Equipes FP experimentees
Zod | Valibot | ArkType | Yup | Joi | io-ts |
|---|---|---|---|---|---|
TypeScript-first, ecosysteme dominant. v4 apporte des gains majeurs de performance. | Ultra-leger, tree-shakeable, API modulaire. Le challenger principal de Zod. | Performance extreme, syntaxe proche du systeme de types TypeScript. | Validation populaire, API fluide, historiquement liee a Formik. | Standard Node.js historique, oriente backend et serveur. | Approche fonctionnelle pure, compose avec fp-ts. |
Avantages
| Avantages
| Avantages
| Avantages
| Avantages
| Avantages
|
Inconvenients
| Inconvenients
| Inconvenients
| Inconvenients
| Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
| Cas d'usage
| Cas d'usage
| Cas d'usage
| Cas d'usage
|
Quand choisir quoi ?
Comment valider votre .env au démarrage de l'app ?
Les variables d'environnement sont un point d'entree critique de votre application.process.env retourne toujours string | undefined, sans aucune garantie de type. Zod permet de valider et typer ces variables au demarrage, pour echouer immediatement si la configuration est invalide.
Le probleme : process.env ment
TypeScript ne peut pas connaitre les variables d'environnement a la compilation. Tout est string | undefined.
- Variable manquante = crash en production a un moment imprevisible
- Pas de type-checking : PORT est toujours une string, jamais un number
- Typo dans le nom de la variable = undefined silencieux
1// Sans validation : bombe a retardement2const apiKey = process.env.API_KEY; // string | undefined3const port = process.env.PORT; // string | undefined, jamais number !4
5// Ce code peut tourner pendant des heures avant de crash6// quand il essaie enfin d'utiliser apiKey7fetch(`https://api.example.com?key=${apiKey}`);8// Si API_KEY est undefined -> URL invalide, erreur opaque9
10// PORT est "3000" (string), pas 3000 (number)11app.listen(parseInt(port!)); // Que se passe-t-il si PORT est undefined ?La solution : schema Zod au demarrage
Definir un schema pour toutes les variables d'environnement et le valider une seule fois au demarrage. Fail fast.
- Erreur immediate si une variable manque ou a un format invalide
- Coercion automatique (string vers number, boolean, etc.)
- Autocompletion dans l'IDE pour toutes les variables
1// lib/env.ts -- valider au demarrage, importer partout2import { z } from 'zod';3
4const EnvSchema = z.object({5 // Base6 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),7 PORT: z.coerce.number().int().positive().default(3000),8
9 // Base de donnees10 DATABASE_URL: z.string().url('DATABASE_URL doit etre une URL valide'),11
12 // Authentication13 JWT_SECRET: z.string().min(32, 'JWT_SECRET doit faire au moins 32 caracteres'),14 JWT_EXPIRES_IN: z.string().default('7d'),15
16 // API externes17 STRIPE_SECRET_KEY: z.string().startsWith('sk_', 'Cle Stripe invalide'),18 STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),19
20 // Email21 SMTP_HOST: z.string().optional(),22 SMTP_PORT: z.coerce.number().optional(),23 SMTP_USER: z.string().optional(),24
25 // Feature flags26 ENABLE_ANALYTICS: z.coerce.boolean().default(false),27 MAX_UPLOAD_SIZE_MB: z.coerce.number().default(10),28});29
30// Valider UNE SEULE FOIS au demarrage31export const env = EnvSchema.parse(process.env);32
33// Type infere automatiquement34export type Env = z.infer<typeof EnvSchema>;35
36// Utilisation dans le code :37// import { env } from '@/lib/env';38// env.PORT -> number (pas string !)39// env.NODE_ENV -> 'development' | 'production' | 'test'40// env.DATABASE_URL -> string (garanti present)1// Pour Next.js : separer variables client et serveur2
3// lib/env-server.ts -- variables serveur uniquement4const ServerEnvSchema = z.object({5 DATABASE_URL: z.string().url(),6 JWT_SECRET: z.string().min(32),7 STRIPE_SECRET_KEY: z.string().startsWith('sk_'),8});9
10export const serverEnv = ServerEnvSchema.parse(process.env);11
12// lib/env-client.ts -- variables publiques (NEXT_PUBLIC_*)13const ClientEnvSchema = z.object({14 NEXT_PUBLIC_APP_URL: z.string().url(),15 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),16 NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),17});18
19export const clientEnv = ClientEnvSchema.parse({20 NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,21 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,22 NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,23});1// Alternative avec t3-env (integration Next.js)2// npm install @t3-oss/env-nextjs3
4import { createEnv } from '@t3-oss/env-nextjs';5import { z } from 'zod';6
7export const env = createEnv({8 server: {9 DATABASE_URL: z.string().url(),10 JWT_SECRET: z.string().min(32),11 NODE_ENV: z.enum(['development', 'production', 'test']),12 },13 client: {14 NEXT_PUBLIC_APP_URL: z.string().url(),15 },16 // Requis pour Next.js17 runtimeEnv: {18 DATABASE_URL: process.env.DATABASE_URL,19 JWT_SECRET: process.env.JWT_SECRET,20 NODE_ENV: process.env.NODE_ENV,21 NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,22 },23});24
25// Avantage t3-env :26// - Empeche l'acces aux variables serveur cote client27// - Validation automatique au build28// - Erreur claire si une variable manqueComment intégrer Zod dans vos formulaires React ?
La validation de formulaires est l'un des cas d'usage les plus courants de Zod. Combine avec React Hook Form et le resolver Zod, vous obtenez une validation type-safe, reactive et avec des messages d'erreur granulaires -- le tout sans ecrire de logique de validation manuelle.
React Hook Form + zodResolver
Le combo le plus utilise pour la validation de formulaires React. Le schema Zod sert a la fois de source de validation et de type.
- Un schema = validation + types : pas de duplication
- Validation reactive : sur blur, change ou submit
- Erreurs par champ : messages specifiques a chaque input
1// npm install react-hook-form @hookform/resolvers zod2
3import { useForm } from 'react-hook-form';4import { zodResolver } from '@hookform/resolvers/zod';5import { z } from 'zod';6
7// 1. Definir le schema8const ContactFormSchema = z.object({9 name: z.string()10 .min(2, 'Le nom doit contenir au moins 2 caracteres')11 .max(50, 'Le nom ne peut pas depasser 50 caracteres'),12 email: z.string()13 .min(1, 'L\'email est requis')14 .email('Format d\'email invalide'),15 subject: z.enum(['general', 'support', 'partnership'], {16 errorMap: () => ({ message: 'Veuillez selectionner un sujet' }),17 }),18 message: z.string()19 .min(10, 'Le message doit contenir au moins 10 caracteres')20 .max(1000, 'Maximum 1000 caracteres'),21 newsletter: z.boolean().default(false),22});23
24// 2. Inferer le type25type ContactForm = z.infer<typeof ContactFormSchema>;26
27// 3. Utiliser dans le composant28export function ContactFormComponent() {29 const {30 register,31 handleSubmit,32 formState: { errors, isSubmitting },33 } = useForm<ContactForm>({34 resolver: zodResolver(ContactFormSchema),35 defaultValues: {36 newsletter: false,37 },38 });39
40 const onSubmit = async (data: ContactForm) => {41 // data est garanti valide et type42 console.log(data);43 };44
45 return (46 <form onSubmit={handleSubmit(onSubmit)}>47 <div>48 <input {...register('name')} placeholder="Votre nom" />49 {errors.name && <p className="text-red-500">{errors.name.message}</p>}50 </div>51
52 <div>53 <input {...register('email')} placeholder="Votre email" />54 {errors.email && <p className="text-red-500">{errors.email.message}</p>}55 </div>56
57 <div>58 <select {...register('subject')}>59 <option value="">Choisir un sujet</option>60 <option value="general">General</option>61 <option value="support">Support</option>62 <option value="partnership">Partenariat</option>63 </select>64 {errors.subject && <p className="text-red-500">{errors.subject.message}</p>}65 </div>66
67 <div>68 <textarea {...register('message')} placeholder="Votre message" />69 {errors.message && <p className="text-red-500">{errors.message.message}</p>}70 </div>71
72 <button type="submit" disabled={isSubmitting}>73 {isSubmitting ? 'Envoi...' : 'Envoyer'}74 </button>75 </form>76 );77}Formulaire d'inscription avec validation cross-field
Cas classique : verification que le mot de passe et sa confirmation correspondent.
1const SignupSchema = z.object({2 username: z.string()3 .min(3, 'Minimum 3 caracteres')4 .max(20, 'Maximum 20 caracteres')5 .regex(/^[a-zA-Z0-9_]+$/, 'Lettres, chiffres et underscores uniquement'),6 email: z.string().email('Email invalide'),7 password: z.string()8 .min(8, 'Minimum 8 caracteres')9 .regex(/[A-Z]/, 'Au moins une majuscule')10 .regex(/[0-9]/, 'Au moins un chiffre'),11 confirmPassword: z.string(),12 acceptTerms: z.literal(true, {13 errorMap: () => ({ message: 'Vous devez accepter les conditions' }),14 }),15}).refine((data) => data.password === data.confirmPassword, {16 message: 'Les mots de passe ne correspondent pas',17 path: ['confirmPassword'],18});19
20// Le type infere inclut tous les champs21type SignupForm = z.infer<typeof SignupSchema>;22
23// Dans le composant :24const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({25 resolver: zodResolver(SignupSchema),26 mode: 'onBlur', // Valider quand l'utilisateur quitte le champ27});28
29// errors.confirmPassword?.message affiche :30// "Les mots de passe ne correspondent pas"31// grace au path: ['confirmPassword'] dans le refine1// Formulaire dynamique avec useFieldArray2import { useForm, useFieldArray } from 'react-hook-form';3import { zodResolver } from '@hookform/resolvers/zod';4import { z } from 'zod';5
6const InvoiceSchema = z.object({7 client: z.string().min(1, 'Client requis'),8 items: z.array(z.object({9 description: z.string().min(1, 'Description requise'),10 quantity: z.number().int().positive('Quantite positive'),11 unitPrice: z.number().positive('Prix positif'),12 })).min(1, 'Au moins un article'),13 notes: z.string().optional(),14});15
16type Invoice = z.infer<typeof InvoiceSchema>;17
18export function InvoiceForm() {19 const { register, control, handleSubmit, formState: { errors } } = useForm<Invoice>({20 resolver: zodResolver(InvoiceSchema),21 defaultValues: {22 items: [{ description: '', quantity: 1, unitPrice: 0 }],23 },24 });25
26 const { fields, append, remove } = useFieldArray({27 control,28 name: 'items',29 });30
31 return (32 <form onSubmit={handleSubmit((data) => console.log(data))}>33 <input {...register('client')} placeholder="Nom du client" />34 {errors.client && <p>{errors.client.message}</p>}35
36 {fields.map((field, index) => (37 <div key={field.id}>38 <input {...register(`items.${index}.description`)} />39 <input {...register(`items.${index}.quantity`, { valueAsNumber: true })} type="number" />40 <input {...register(`items.${index}.unitPrice`, { valueAsNumber: true })} type="number" />41 <button type="button" onClick={() => remove(index)}>Supprimer</button>42 {errors.items?.[index]?.description && (43 <p>{errors.items[index].description.message}</p>44 )}45 </div>46 ))}47
48 <button type="button" onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}>49 Ajouter un article50 </button>51 <button type="submit">Creer la facture</button>52 </form>53 );54}Retour d'experience -- Scanorr
Sur Scanorr, j'utilise ce pattern Zod + React Hook Form pour tous les formulaires de saisie. Le gain principal : les messages d'erreur sont definis une seule fois dans le schema, et ils sont automatiquement affiches au bon endroit dans le formulaire. Quand le schema change (par exemple, ajout d'un champ obligatoire), TypeScript signale immediatement tous les endroits a mettre a jour. Ce pattern a considerablement reduit les bugs lies aux formulaires.
Comment valider les données côté serveur avec Zod ?
Les Server Actions de Next.js recoivent des FormData non types depuis le client. Meme si votre formulaire est valide cote client, vous devez toujours revalider cote serveur -- un utilisateur malveillant peut envoyer n'importe quoi directement a votre endpoint.
Pourquoi revalider cote serveur ?
La validation client est une commodite UX. La validation serveur est une necessite de securite.
- Un curl ou un script peut contourner toute validation client
- Les DevTools permettent de modifier le DOM et les formulaires
- Zod valide les memes schemas cote client ET serveur
1// Pattern de base : Server Action avec Zod2'use server';3
4import { z } from 'zod';5
6const CreatePostSchema = z.object({7 title: z.string().min(1, 'Le titre est requis').max(200),8 content: z.string().min(10, 'Minimum 10 caracteres'),9 category: z.enum(['tech', 'design', 'business']),10 published: z.boolean().default(false),11});12
13type ActionState = {14 success: boolean;15 errors?: Record<string, string[]>;16 message?: string;17};18
19export async function createPost(20 prevState: ActionState,21 formData: FormData,22): Promise<ActionState> {23 // 1. Extraire et valider les donnees24 const result = CreatePostSchema.safeParse({25 title: formData.get('title'),26 content: formData.get('content'),27 category: formData.get('category'),28 published: formData.get('published') === 'on',29 });30
31 // 2. Retourner les erreurs si invalide32 if (!result.success) {33 return {34 success: false,35 errors: result.error.flatten().fieldErrors,36 };37 }38
39 // 3. Les donnees sont validees et typees40 const { title, content, category, published } = result.data;41
42 try {43 await db.post.create({44 data: { title, content, category, published },45 });46 return { success: true, message: 'Article cree avec succes' };47 } catch {48 return { success: false, message: 'Erreur lors de la creation' };49 }50}Utilisation avec useActionState
Le hook useActionState de React 19 se combine naturellement avec les Server Actions validees par Zod.
1'use client';2
3import { useActionState } from 'react';4import { createPost } from '@/app/actions/create-post';5
6export function CreatePostForm() {7 const [state, formAction, isPending] = useActionState(createPost, {8 success: false,9 });10
11 return (12 <form action={formAction}>13 <div>14 <label htmlFor="title">Titre</label>15 <input id="title" name="title" />16 {state.errors?.title && (17 <p className="text-red-500 text-sm">{state.errors.title[0]}</p>18 )}19 </div>20
21 <div>22 <label htmlFor="content">Contenu</label>23 <textarea id="content" name="content" />24 {state.errors?.content && (25 <p className="text-red-500 text-sm">{state.errors.content[0]}</p>26 )}27 </div>28
29 <div>30 <label htmlFor="category">Categorie</label>31 <select id="category" name="category">32 <option value="">Choisir</option>33 <option value="tech">Tech</option>34 <option value="design">Design</option>35 <option value="business">Business</option>36 </select>37 {state.errors?.category && (38 <p className="text-red-500 text-sm">{state.errors.category[0]}</p>39 )}40 </div>41
42 <div>43 <label>44 <input type="checkbox" name="published" />45 Publier immediatement46 </label>47 </div>48
49 <button type="submit" disabled={isPending}>50 {isPending ? 'Creation...' : 'Creer l\'article'}51 </button>52
53 {state.success && (54 <p className="text-green-600">{state.message}</p>55 )}56 {!state.success && state.message && (57 <p className="text-red-500">{state.message}</p>58 )}59 </form>60 );61}Pattern avance : schema partage client/serveur
Definir le schema dans un fichier partage pour que la validation client et serveur utilisent exactement les memes regles.
1// schemas/post.ts -- fichier partage (ni 'use client' ni 'use server')2import { z } from 'zod';3
4export const CreatePostSchema = z.object({5 title: z.string().min(1, 'Le titre est requis').max(200, 'Maximum 200 caracteres'),6 content: z.string().min(10, 'Minimum 10 caracteres'),7 category: z.enum(['tech', 'design', 'business']),8 published: z.boolean().default(false),9});10
11export type CreatePostInput = z.infer<typeof CreatePostSchema>;12
13// --------------------14// app/actions/create-post.ts -- Server Action15'use server';16import { CreatePostSchema } from '@/schemas/post';17
18export async function createPost(prevState: ActionState, formData: FormData) {19 const result = CreatePostSchema.safeParse(/* ... */);20 // ...21}22
23// --------------------24// components/create-post-form.tsx -- Client25'use client';26import { CreatePostSchema, type CreatePostInput } from '@/schemas/post';27import { zodResolver } from '@hookform/resolvers/zod';28
29// Meme schema pour la validation client (React Hook Form)30// ET pour la validation serveur (Server Action)31// -> Zero duplication, zero desynchronisationComment garantir la fiabilité de vos endpoints API ?
Les APIs sont une frontiere de confiance. Que vous receviez des requetes ou consommiez des APIs externes, les donnees doivent etre validees. Zod s'integre naturellement dans les Route Handlers Next.js et avec tRPC pour une validation de bout en bout.
Valider les requetes entrantes
Chaque Route Handler devrait valider le body, les query params et les headers avant de les utiliser.
- Le body d'une requete est toujours
unknownen realite - Les query params sont toujours des strings
- safeParse retourne des erreurs structurees pour une reponse 400 propre
1// app/api/users/route.ts -- Next.js Route Handler2import { z } from 'zod';3import { NextRequest, NextResponse } from 'next/server';4
5const CreateUserSchema = z.object({6 name: z.string().min(1),7 email: z.string().email(),8 role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),9});10
11export async function POST(request: NextRequest) {12 // 1. Parser le body13 let body: unknown;14 try {15 body = await request.json();16 } catch {17 return NextResponse.json(18 { error: 'Body JSON invalide' },19 { status: 400 },20 );21 }22
23 // 2. Valider avec Zod24 const result = CreateUserSchema.safeParse(body);25
26 if (!result.success) {27 return NextResponse.json(28 {29 error: 'Donnees invalides',30 details: result.error.flatten().fieldErrors,31 },32 { status: 400 },33 );34 }35
36 // 3. Utiliser les donnees validees37 const user = await db.user.create({ data: result.data });38
39 return NextResponse.json(user, { status: 201 });40}1// Valider les query params2// GET /api/users?page=2&limit=50&role=admin3
4const QueryParamsSchema = z.object({5 page: z.coerce.number().int().positive().default(1),6 limit: z.coerce.number().int().min(1).max(100).default(20),7 role: z.enum(['admin', 'editor', 'viewer']).optional(),8 search: z.string().optional(),9});10
11export async function GET(request: NextRequest) {12 const searchParams = Object.fromEntries(request.nextUrl.searchParams);13 const result = QueryParamsSchema.safeParse(searchParams);14
15 if (!result.success) {16 return NextResponse.json(17 { error: 'Parametres invalides', details: result.error.flatten().fieldErrors },18 { status: 400 },19 );20 }21
22 const { page, limit, role, search } = result.data;23 // page: number, limit: number, role?: string, search?: string24 // Les strings ont ete converties en nombres grace a z.coerce25
26 const users = await db.user.findMany({27 where: {28 ...(role && { role }),29 ...(search && { name: { contains: search } }),30 },31 skip: (page - 1) * limit,32 take: limit,33 });34
35 return NextResponse.json({ data: users, page, limit });36}Valider les reponses d'API externes
Ne jamais faire confiance aux donnees d'une API que vous ne controlez pas. Validez les reponses comme vous validez les inputs utilisateur.
1// Valider les reponses d'API externes2const GitHubUserSchema = z.object({3 id: z.number(),4 login: z.string(),5 avatar_url: z.string().url(),6 html_url: z.string().url(),7 name: z.string().nullable(),8 bio: z.string().nullable(),9 public_repos: z.number(),10});11
12type GitHubUser = z.infer<typeof GitHubUserSchema>;13
14async function fetchGitHubUser(username: string): Promise<GitHubUser> {15 const response = await fetch(`https://api.github.com/users/${username}`);16
17 if (!response.ok) {18 throw new Error(`GitHub API error: ${response.status}`);19 }20
21 const data = await response.json();22
23 // Valider la reponse -- si GitHub change son API, on le sait immediatement24 return GitHubUserSchema.parse(data);25}26
27// Variante avec safeParse pour une gestion plus fine28async function fetchGitHubUserSafe(username: string) {29 const response = await fetch(`https://api.github.com/users/${username}`);30 const data = await response.json();31 const result = GitHubUserSchema.safeParse(data);32
33 if (!result.success) {34 console.error('API GitHub a change son format:', result.error.issues);35 // Alerter, logger, fallback...36 return null;37 }38
39 return result.data;40}1// Integration tRPC -- validation de bout en bout2import { initTRPC } from '@trpc/server';3import { z } from 'zod';4
5const t = initTRPC.create();6
7const appRouter = t.router({8 // Input valide automatiquement par Zod9 getUser: t.procedure10 .input(z.object({11 id: z.string().uuid(),12 }))13 .query(async ({ input }) => {14 // input.id est type string (garanti uuid)15 const user = await db.user.findUnique({ where: { id: input.id } });16 return user;17 }),18
19 createUser: t.procedure20 .input(z.object({21 name: z.string().min(1),22 email: z.string().email(),23 role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),24 }))25 .mutation(async ({ input }) => {26 // input est valide et type automatiquement27 return await db.user.create({ data: input });28 }),29});30
31// Cote client, les types sont inferes automatiquement32// trpc.getUser.useQuery({ id: '...' })33// trpc.createUser.useMutation()Retour d'experience -- Scanorr
Sur Scanorr, la validation des objets API avec Zod a ete un changement majeur. Avant, les erreurs liees a des donnees mal formees apparaissaient en production, souvent dans des endroits eloignes de la source du probleme. Avec Zod, chaque reponse d'API externe est validee des la reception. Quand un format change, l'erreur est immediate et precise, ce qui a drastiquement reduit le temps de diagnostic.
Quels patterns avancés pour des validations complexes ?
Zod va bien au-dela de la validation basique. Schemas recursifs, branded types, generiques et schemas custom permettent de modeliser des structures de donnees complexes tout en gardant la type-safety.
z.lazy() -- Schemas recursifs
Pour les structures de donnees qui se referent a elles-memes : arbres, menus imbriques, commentaires avec reponses.
1import { z } from 'zod';2
3// Arbre de fichiers recursif4type FileNode = {5 name: string;6 type: 'file' | 'folder';7 children?: FileNode[];8};9
10const FileNodeSchema: z.ZodType<FileNode> = z.object({11 name: z.string().min(1),12 type: z.enum(['file', 'folder']),13 children: z.lazy(() => z.array(FileNodeSchema)).optional(),14});15
16// Valide une arborescence complete17FileNodeSchema.parse({18 name: 'src',19 type: 'folder',20 children: [21 { name: 'index.ts', type: 'file' },22 {23 name: 'components',24 type: 'folder',25 children: [26 { name: 'Button.tsx', type: 'file' },27 { name: 'Card.tsx', type: 'file' },28 ],29 },30 ],31});1// Commentaires avec reponses imbriquees2type Comment = {3 id: string;4 author: string;5 content: string;6 createdAt: string;7 replies: Comment[];8};9
10const CommentSchema: z.ZodType<Comment> = z.object({11 id: z.string().uuid(),12 author: z.string().min(1),13 content: z.string().min(1),14 createdAt: z.string().datetime(),15 replies: z.lazy(() => z.array(CommentSchema)),16});17
18// Menu de navigation recursif19type MenuItem = {20 label: string;21 href?: string;22 children?: MenuItem[];23};24
25const MenuItemSchema: z.ZodType<MenuItem> = z.object({26 label: z.string(),27 href: z.string().url().optional(),28 children: z.lazy(() => z.array(MenuItemSchema)).optional(),29});z.brand() -- Branded types
Creer des types nominaux pour distinguer des valeurs qui ont le meme type structurel mais des semantiques differentes.
1// Sans branded types : rien n'empeche de melanger les IDs2function getUser(id: string) { /* ... */ }3function getPost(id: string) { /* ... */ }4
5getUser(postId); // Pas d'erreur TypeScript, mais bug logique !6
7// Avec branded types : erreur a la compilation8const UserIdSchema = z.string().uuid().brand<'UserId'>();9const PostIdSchema = z.string().uuid().brand<'PostId'>();10
11type UserId = z.infer<typeof UserIdSchema>; // string & { __brand: 'UserId' }12type PostId = z.infer<typeof PostIdSchema>; // string & { __brand: 'PostId' }13
14function getUserSafe(id: UserId) { /* ... */ }15function getPostSafe(id: PostId) { /* ... */ }16
17const userId = UserIdSchema.parse('550e8400-e29b-41d4-a716-446655440000');18const postId = PostIdSchema.parse('660e8400-e29b-41d4-a716-446655440001');19
20getUserSafe(userId); // OK21getUserSafe(postId); // Erreur TypeScript !22// Argument of type 'string & Brand<"PostId">'23// is not assignable to parameter of type 'string & Brand<"UserId">'24
25// Autres exemples utiles26const EmailSchema = z.string().email().brand<'Email'>();27const MoneySchema = z.number().positive().brand<'Money'>();28const UrlSchema = z.string().url().brand<'SafeUrl'>();Schemas generiques (factory pattern)
Creer des fonctions qui retournent des schemas parametres pour eviter la repetition.
1// Schema factory : reponse paginee generique2function paginatedResponse<T extends z.ZodTypeAny>(itemSchema: T) {3 return z.object({4 data: z.array(itemSchema),5 meta: z.object({6 page: z.number().int().positive(),7 limit: z.number().int().positive(),8 total: z.number().int().nonnegative(),9 totalPages: z.number().int().nonnegative(),10 }),11 });12}13
14// Utilisation15const UserSchema = z.object({ id: z.string(), name: z.string() });16const PaginatedUsersSchema = paginatedResponse(UserSchema);17
18type PaginatedUsers = z.infer<typeof PaginatedUsersSchema>;19// { data: { id: string; name: string }[]; meta: { page: number; ... } }20
21// Schema factory : reponse API generique22function apiResponse<T extends z.ZodTypeAny>(dataSchema: T) {23 return z.discriminatedUnion('status', [24 z.object({25 status: z.literal('success'),26 data: dataSchema,27 }),28 z.object({29 status: z.literal('error'),30 error: z.object({31 code: z.string(),32 message: z.string(),33 details: z.unknown().optional(),34 }),35 }),36 ]);37}38
39const UserResponseSchema = apiResponse(UserSchema);40type UserResponse = z.infer<typeof UserResponseSchema>;41// { status: 'success'; data: { id: string; name: string } }42// | { status: 'error'; error: { code: string; message: string; ... } }z.custom() et preprocess
Pour les cas ou les schemas natifs de Zod ne suffisent pas.
1// z.custom() -- validation entierement custom2const FileSchema = z.custom<File>(3 (val) => val instanceof File,4 { message: 'Un fichier est requis' }5);6
7// Schema avec validation custom complexe8const ImageFileSchema = z.custom<File>(9 (val) => {10 if (!(val instanceof File)) return false;11 if (!['image/jpeg', 'image/png', 'image/webp'].includes(val.type)) return false;12 if (val.size > 5 * 1024 * 1024) return false; // 5 MB max13 return true;14 },15 { message: 'Image JPEG, PNG ou WebP de 5 MB maximum' }16);17
18// z.preprocess() -- transformer AVANT la validation19const FlexibleNumberSchema = z.preprocess(20 (val) => {21 if (typeof val === 'string') return Number(val);22 return val;23 },24 z.number().positive()25);26
27FlexibleNumberSchema.parse('42'); // 4228FlexibleNumberSchema.parse(42); // 4229FlexibleNumberSchema.parse('abc'); // ZodError (NaN n'est pas positif)30
31// Discriminated union pour state machine32const TaskStateSchema = z.discriminatedUnion('status', [33 z.object({34 status: z.literal('idle'),35 }),36 z.object({37 status: z.literal('loading'),38 startedAt: z.date(),39 }),40 z.object({41 status: z.literal('success'),42 data: z.unknown(),43 completedAt: z.date(),44 }),45 z.object({46 status: z.literal('error'),47 error: z.string(),48 failedAt: z.date(),49 retryCount: z.number().int().nonnegative(),50 }),51]);52
53type TaskState = z.infer<typeof TaskStateSchema>;54// Union discriminee sur 'status' -- TypeScript narrowe automatiquementQue change Zod v4 et comment migrer ?
Zod n'est pas qu'une librairie isolee -- c'est le centre d'un ecosysteme de validation TypeScript. Cette section couvre les librairies complementaires, les differences entre v3 et v4, et le futur de la validation avec Standard Schema.
Ecosysteme Zod
Des dizaines de librairies s'integrent avec Zod pour couvrir des cas d'usage specifiques.
- @hookform/resolvers : integration React Hook Form
- tRPC : validation input/output de bout en bout
- zod-form-data : parser les FormData avec Zod
- zod-i18n-map : messages d'erreur multilingues
- @t3-oss/env-nextjs : validation env vars type-safe
- zodios : client HTTP type-safe base sur Zod
1// zod-form-data : parser les FormData directement2import { zfd } from 'zod-form-data';3
4const UploadSchema = zfd.formData({5 file: zfd.file(z.instanceof(File)),6 title: zfd.text(z.string().min(1)),7 tags: zfd.repeatable(z.array(z.string())),8});9
10// Dans un Server Action11export async function uploadFile(formData: FormData) {12 const result = UploadSchema.safeParse(formData);13 // result.data.file -> File14 // result.data.title -> string15 // result.data.tags -> string[]16}17
18// ---19
20// zod-i18n-map : messages multilingues21import { z } from 'zod';22import { zodI18nMap } from 'zod-i18n-map';23import translation from 'zod-i18n-map/locales/fr/zod.json';24import i18next from 'i18next';25
26i18next.init({27 lng: 'fr',28 resources: { fr: { zod: translation } },29});30z.setErrorMap(zodI18nMap);31
32// Maintenant les erreurs Zod sont en francais33z.string().min(3).safeParse('ab');34// Error: "La chaine doit contenir au moins 3 caractere(s)"Zod v3 vs v4 -- Quoi de neuf ?
Zod v4 apporte des gains de performance majeurs, un core plus leger et de nouvelles fonctionnalites.
- Ecosysteme tres mature
- Documentation exhaustive
- Compatible avec tous les outils actuels
- Tres stable en production
- Performance lente (< 1M ops/sec)
- Bundle size ~33 KB
- Pas de JSON Schema natif
- Pas de Standard Schema
- •Projets en production stable
- •Migration progressive vers v4
- 14x plus rapide sur les strings
- Core 57% plus petit (~17 KB)
- .toJSONSchema() natif
- Standard Schema compatible
- Discriminated unions composables
- Breaking changes a migrer
- Certains plugins pas encore compatibles
- Moins de retours production
- •Nouveaux projets
- •Projets qui necessitent JSON Schema
- •Besoin de meilleures performances
Zod v3 | Zod v4 |
|---|---|
Version stable largement adoptee. La plus utilisee en production. | Version nouvelle generation avec gains majeurs de performance et features. |
Avantages
| Avantages
|
Inconvenients
| Inconvenients
|
Cas d'usage
| Cas d'usage
|
1// Changements cles v3 -> v42
3// 1. .toJSONSchema() -- natif en v44import { z } from 'zod/v4';5
6const UserSchema = z.object({7 name: z.string().min(1),8 email: z.string().email(),9 age: z.number().int().optional(),10});11
12const jsonSchema = UserSchema.toJSONSchema();13// {14// type: 'object',15// properties: {16// name: { type: 'string', minLength: 1 },17// email: { type: 'string', format: 'email' },18// age: { type: 'integer' },19// },20// required: ['name', 'email'],21// }22
23// 2. @zod/mini -- sous-package ultra leger24import { z } from '@zod/mini';25// ~1.9 KB gzipped, ideal pour le frontend26
27// 3. Discriminated unions composables28const BaseEvent = z.discriminatedUnion('type', [29 z.object({ type: z.literal('click'), x: z.number() }),30 z.object({ type: z.literal('scroll'), y: z.number() }),31]);32
33// En v4, on peut etendre une discriminated union34const ExtendedEvent = z.discriminatedUnion('type', [35 ...BaseEvent.options,36 z.object({ type: z.literal('keypress'), key: z.string() }),37]);Standard Schema -- Le futur de la validation
Une specification commune pour que les librairies de validation (Zod, Valibot, ArkType) soient interchangeables.
1// Standard Schema : interoperabilite entre librairies2// Zod v4, Valibot et ArkType implementent la spec Standard Schema3
4// Avec Standard Schema, les outils n'ont plus besoin de resolvers specifiques5// React Hook Form pourra accepter n'importe quelle librairie compatible6
7// Avant (specifique a chaque librairie)8import { zodResolver } from '@hookform/resolvers/zod';9import { valibotResolver } from '@hookform/resolvers/valibot';10
11// Apres (Standard Schema -- universel)12import { standardSchemaResolver } from '@hookform/resolvers';13
14// Fonctionne avec Zod, Valibot, ArkType, etc.15useForm({16 resolver: standardSchemaResolver(anySchemaFromAnyLibrary),17});18
19// En pratique, cela signifie que vous pouvez :20// 1. Commencer avec Zod (ecosysteme mature)21// 2. Migrer vers Valibot (si bundle size critique)22// 3. Sans changer le code d'integration23
24// Zod v4 est Standard Schema compatible par defaut25// Pas de configuration supplementaireEn resume
Zod est aujourd'hui le standard de facto pour la validation TypeScript. Son ecosysteme (tRPC, React Hook Form, shadcn/ui, t3-env) en fait un choix incontournable pour les projets TypeScript modernes.
Les alternatives comme Valibot et ArkType progressent rapidement et offrent de meilleures performances, mais l'ecosysteme de Zod reste incomparable. Avec Standard Schema, ces librairies deviendront interchangeables, rendant le choix moins engageant.
Pour un nouveau projet en 2026 : commencez avec Zod, et migrez vers Valibot ou ArkType si (et seulement si) le bundle size ou la performance runtime deviennent un vrai probleme mesure.
Felicitations !
Vous avez termine ce guide.