Maxpaths
Fondamentaux·Section 1/13

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
probleme-sans-validation.tstypescript
1// Le probleme : TypeScript fait confiance aveuglément
2interface User {
3 id: number;
4 name: string;
5 email: string;
6}
7
8// TypeScript ne verifie PAS que la reponse est conforme
9const response = await fetch('/api/users/1');
10const user: User = await response.json(); // Dangereux !
11// Si l'API renvoie { id: "abc", nom: "Jean" }, aucune erreur
12// Le crash arrive plus tard, dans un endroit inattendu

Ce 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 = data devient simplement const user = data en JavaScript
  • Les interface, type et as User sont 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
compilation-effacement.tstypescript
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 2
11console.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 function
33// 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
solution-zod.tstypescript
1import { z } from 'zod';
2
3// 1. Definir le schema
4const 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 automatiquement
11type User = z.infer<typeof UserSchema>;
12// => { id: number; name: string; email: string }
13
14// 3. Valider les donnees a l'execution
15const 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 details

Retour 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.

Fondamentaux·Section 2/13

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.

types-primitifs.tstypescript
1import { z } from 'zod';
2
3// Types de base
4const stringSchema = z.string(); // string
5const numberSchema = z.number(); // number
6const boolSchema = z.boolean(); // boolean
7const dateSchema = z.date(); // Date
8const bigintSchema = z.bigint(); // bigint
9
10// Types speciaux
11const undefinedSchema = z.undefined(); // undefined
12const nullSchema = z.null(); // null
13const voidSchema = z.void(); // void
14const anySchema = z.any(); // any (a eviter)
15const unknownSchema = z.unknown(); // unknown (prefere a any)
16const neverSchema = z.never(); // never
17
18// Litteraux
19const tealSchema = z.literal('teal'); // 'teal'
20const fortyTwoSchema = z.literal(42); // 42
21const trueSchema = z.literal(true); // true

Validateurs de chaines

z.string() accepte des dizaines de methodes chainables pour valider le format, la longueur et le contenu.

validateurs-string.tstypescript
1const emailSchema = z.string().email('Email invalide');
2const urlSchema = z.string().url('URL invalide');
3const uuidSchema = z.string().uuid('UUID invalide');
4
5// Longueur
6const usernameSchema = z.string()
7 .min(3, 'Minimum 3 caracteres')
8 .max(20, 'Maximum 20 caracteres');
9
10// Regex
11const slugSchema = z.string()
12 .regex(/^[a-z0-9-]+$/, 'Slug invalide : lettres minuscules, chiffres et tirets uniquement');
13
14// Transformations de chaines
15const normalizedEmail = z.string()
16 .email()
17 .trim()
18 .toLowerCase();
19
20// Formats courants
21const ipSchema = z.string().ip(); // IPv4 ou IPv6
22const cidrSchema = z.string().cidr(); // Notation CIDR
23const emojiSchema = z.string().emoji(); // Emojis uniquement
24const datetimeSchema = z.string().datetime(); // ISO 8601
25const nanoidSchema = z.string().nanoid(); // Nanoid

Validateurs de nombres

z.number() offre des contraintes numeriques precises pour valider intervalles, entiers et proprietes mathematiques.

validateurs-number.tstypescript
1// Contraintes numeriques
2const 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 utiles
16z.number().positive(); // > 0
17z.number().nonnegative(); // >= 0
18z.number().negative(); // < 0
19z.number().nonpositive(); // <= 0
20z.number().int(); // entier
21z.number().finite(); // pas Infinity
22z.number().safe(); // dans Number.MIN_SAFE_INTEGER..MAX_SAFE_INTEGER

Optionnel, nullable et valeurs par defaut

Trois methodes pour gerer l'absence de valeur, chacune avec un comportement distinct.

optionnel-nullable-default.tstypescript
1const schema = z.object({
2 // Requis : doit etre present et non-null
3 name: z.string(),
4
5 // Optionnel : string | undefined
6 bio: z.string().optional(),
7
8 // Nullable : string | null
9 avatar: z.string().url().nullable(),
10
11 // Nullish : string | null | undefined
12 nickname: z.string().nullish(),
13
14 // Valeur par defaut : si absent, utilise la valeur fournie
15 role: z.enum(['admin', 'user', 'viewer']).default('viewer'),
16
17 // Optionnel avec defaut : toujours present dans le resultat
18 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 default
28// notifications: boolean; // jamais undefined grace a default
29// }
Fondamentaux·Section 3/13

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
parse-example.tstypescript
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 invalide
10try {
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
safe-parse-example.tstypescript
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 ZodError
12 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.

error-formatting.tstypescript
1const result = UserSchema.safeParse({ name: '', email: 'bad', age: -1 });
2
3if (!result.success) {
4 // flatten() -- ideal pour les formulaires
5 const flat = result.error.flatten();
6 // {
7 // formErrors: [], // erreurs globales
8 // 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 imbriquee
16 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 erreur
25 result.error.issues.forEach((issue) => {
26 console.log(`${issue.path.join('.')}: ${issue.message}`);
27 });
28}
custom-error-messages.tstypescript
1// Messages d'erreur personnalises
2const 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 francais
22const 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
console-debug.tstypescript
1// SANS ZOD : erreur generique, difficile a tracer
2const 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 immediatement
8const 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 recu
29}
30
31// Personnaliser TOUS les messages globalement avec setErrorMap
32import { z } from 'zod';
33
34const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
35 // Personnaliser les erreurs de type
36 if (issue.code === z.ZodIssueCode.invalid_type) {
37 return { message: `Attendu ${issue.expected}, recu ${issue.received}` };
38 }
39 // Personnaliser les erreurs de longueur
40 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 personnalises

Double 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
rhf-double-filet.tsxtsx
1// Pattern "double filet" : erreurs UI + console
2import { 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 VALIDE
19 function onValid(data: LoginForm) {
20 console.log('Donnees validees :', data);
21 }
22
23 // Callback quand le formulaire est INVALIDE
24 // -> handleSubmit accepte un 2e argument pour capturer les erreurs
25 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 DOM
33 }
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 formulaire
52 // ET le developpeur voit la structure complete dans la console du navigateur
53}

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
Modes de Rendu·Section 4/13

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.

object-schema.tstypescript
1import { z } from 'zod';
2
3// Schema d'un utilisateur complet
4const 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.

array-tuple-schemas.tstypescript
1// Tableau de strings
2const tagsSchema = z.array(z.string());
3
4// Contraintes sur le tableau
5const 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'objets
11const 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 position
22const CoordinatesSchema = z.tuple([
23 z.number(), // latitude
24 z.number(), // longitude
25]);
26// type : [number, number]
27
28// Tuple avec rest element
29const LogEntrySchema = z.tuple([
30 z.date(), // timestamp
31 z.string(), // level
32]).rest(z.string()); // ...messages
33// type : [Date, string, ...string[]]

z.enum() et z.nativeEnum()

Deux approches pour valider des valeurs parmi un ensemble fini.

enum-record-schemas.tstypescript
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 valeurs
6RoleSchema.options; // ['admin', 'editor', 'viewer']
7RoleSchema.enum; // { admin: 'admin', editor: 'editor', viewer: 'viewer' }
8
9// z.nativeEnum() -- enum TypeScript existante
10enum Status {
11 Active = 'active',
12 Inactive = 'inactive',
13 Pending = 'pending',
14}
15const StatusSchema = z.nativeEnum(Status);
16type StatusType = z.infer<typeof StatusSchema>; // Status
17
18// Record -- objet avec cles dynamiques
19const SettingsSchema = z.record(z.string(), z.boolean());
20// type : Record<string, boolean>
21// Valide : { darkMode: true, notifications: false }
22
23// Record avec cle contrainte
24const 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.

union-schemas.tstypescript
1// Union simple -- essaie chaque schema dans l'ordre
2const 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 discriminant
7const 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 rapide
intersection-schemas.tstypescript
1// Intersection -- combiner deux schemas
2const 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 }
Modes de Rendu·Section 5/13

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
composable-schemas.tstypescript
1import { z } from 'zod';
2
3// Schema de base -- la source de verite
4const 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'id
25const 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 timestamps
35const 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.

extend-merge.tstypescript
1// extend() -- ajouter des proprietes
2const 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 objets
17const 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 schemas
28const FullArticleSchema = PublishedArticleSchema
29 .merge(TimestampSchema)
30 .merge(SoftDeleteSchema);
31
32type FullArticle = z.infer<typeof FullArticleSchema>;
33// Combine toutes les proprietes des 3 schemas

Gestion des proprietes inconnues

Par defaut, Zod supprime les proprietes non definies dans le schema. Trois methodes pour controler ce comportement.

unknown-properties.tstypescript
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 supprimes
11
12// strict() -- rejette les proprietes inconnues
13ProductSchema.strict().parse(input);
14// ZodError: Unrecognized key(s) in object: 'discount', 'sku'
15
16// passthrough() -- conserve les proprietes inconnues
17ProductSchema.passthrough().parse(input);
18// { name: 'Widget', price: 9.99, discount: 0.1, sku: 'W-001' }
19
20// catchall() -- valide les proprietes inconnues avec un schema
21ProductSchema.catchall(z.string()).parse({
22 name: 'Widget',
23 price: 9.99,
24 note: 'fragile', // OK : string
25});
schemas/user.tstypescript
1// Pattern reel : schemas partages client/serveur
2// schemas/user.ts -- fichier partage
3
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 = UserBaseSchema
29 .pick({ name: true, email: true })
30 .partial();
31
32// Tous les types inferes automatiquement
33export type SignupForm = z.infer<typeof SignupFormSchema>;
34export type UserResponse = z.infer<typeof UserResponseSchema>;
35export type UpdateProfile = z.infer<typeof UpdateProfileSchema>;
Modes de Rendu·Section 6/13

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.

transform-basic.tstypescript
1import { z } from 'zod';
2
3// Transformer une string en nombre
4const NumericStringSchema = z.string()
5 .transform((val) => parseInt(val, 10));
6// Input : string -> Output : number
7
8NumericStringSchema.parse('42'); // 42 (number)
9NumericStringSchema.parse('abc'); // NaN (attention !)
10
11// Transformer et valider le resultat avec pipe
12const 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 positif
16
17SafeNumericSchema.parse('42'); // 42
18SafeNumericSchema.parse('abc'); // ZodError (NaN n'est pas un entier positif)
19SafeNumericSchema.parse('-5'); // ZodError (pas positif)
coercion.tstypescript
1// z.coerce -- coercion automatique (plus simple que transform)
2const CoercedNumber = z.coerce.number();
3CoercedNumber.parse('42'); // 42
4CoercedNumber.parse(true); // 1
5
6const CoercedDate = z.coerce.date();
7CoercedDate.parse('2024-01-15'); // Date object
8CoercedDate.parse(1705276800000); // Date object (timestamp)
9
10const CoercedBoolean = z.coerce.boolean();
11CoercedBoolean.parse('true'); // true
12CoercedBoolean.parse(''); // false
13CoercedBoolean.parse(0); // false
14CoercedBoolean.parse(1); // true
15
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=asc
24PaginationSchema.parse({ page: '2', limit: '50', sort: 'asc' });
25// { page: 2, limit: 50, sort: 'asc' } -- strings converties en nombres

Transforms avances

Les transforms peuvent normaliser, formatter ou enrichir les donnees validees.

transforms-advanced.tstypescript
1// Normaliser un slug
2const 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 complet
10const 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 transform
32// 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.

refine-examples.tstypescript
1// Refine simple avec predicat
2const 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 passe
8const 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 confirmPassword
14});
15
16// Validation cross-field : dates coherentes
17const 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 externe
26const 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 parseAsync
35await UniqueEmailSchema.parseAsync('jean@example.com');

.superRefine() -- Validation avancee

Contrairement a refine(), superRefine() donne acces au contexte complet et permet d'ajouter plusieurs erreurs.

superrefine-example.tstypescript
1// superRefine -- plusieurs erreurs a la fois
2const 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 coup
33const result = StrongPasswordSchema.safeParse('abc');
34// 4 erreurs simultanees au lieu d'une seule
pipe-example.tstypescript
1// pipe() -- chainer des schemas
2// Utile pour separer validation et transformation
3const DateStringSchema = z.string()
4 .datetime() // 1. Valide que c'est un ISO datetime
5 .pipe(z.coerce.date()) // 2. Convertit en Date
6 .pipe(z.date().min( // 3. Verifie que la date est dans le futur
7 new Date(),
8 'La date doit etre dans le futur'
9 ));
10
11// Chaque etape du pipe est independante et reutilisable
12const ISODateTimeSchema = z.string().datetime();
13const FutureDateSchema = z.date().min(new Date());
14
15// Equivalent compose
16const ComposedSchema = ISODateTimeSchema
17 .pipe(z.coerce.date())
18 .pipe(FutureDateSchema);
Optimisations·Section 7/13

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
benchmarks-comparaison.txttext
1// Taille de bundle comparee (gzipped)
2// Zod : ~17 KB
3// 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 KB
7// Joi : ~30 KB (non concu pour le frontend)
8
9// Benchmarks approximatifs (operations/sec, objets simples)
10// ArkType : ~76M ops/sec
11// Valibot : ~25M ops/sec
12// Zod v4 : ~6.7M ops/sec
13// Zod v3 : < 1M ops/sec
14// Yup : ~800K ops/sec
15
16// Pour la majorite des applications, Zod est largement suffisant.
17// La difference ne se ressent qu'en haute charge
18// (ex: valider 500K+ objets en batch).
Zod
TypeScript-first, ecosysteme dominant. v4 apporte des gains majeurs de performance.
Avantages
  • TypeScript-first, z.infer natif
  • Zero dependances
  • Ecosysteme riche (tRPC, RHF, shadcn)
  • v4 : 14x plus rapide sur les strings
Inconvenients
  • Bundle ~17 KB gzipped
  • Plus lent que ArkType (3-4x)
  • Overhead memoire (immutable)
  • TS compiler overhead
Cas d'usage
  • Applications TypeScript modernes
  • Stack tRPC / Next.js
  • Formulaires avec React Hook Form
Valibot
Ultra-leger, tree-shakeable, API modulaire. Le challenger principal de Zod.
Avantages
  • ~1.37 KB gzipped (90% plus leger)
  • Tree-shakeable par design
  • API pipe() composable
  • Standard Schema compatible
Inconvenients
  • Ecosysteme plus restreint
  • Moins de tutoriels et exemples
  • API differente de Zod
Cas d'usage
  • SPAs ou le bundle size est critique
  • Edge functions et workers
  • Projets performance-first
ArkType
Performance extreme, syntaxe proche du systeme de types TypeScript.
Avantages
  • 3-4x plus rapide que Zod
  • Syntaxe concise et expressive
  • Inference de types avancee
Inconvenients
  • API non conventionnelle
  • Ecosysteme naissant
  • Documentation en evolution
Cas d'usage
  • Hot paths performance-critiques
  • APIs a haute charge
  • Projets ou la vitesse prime
Yup
Validation populaire, API fluide, historiquement liee a Formik.
Avantages
  • API intuitive et fluide
  • Bonne integration Formik
  • Mature et stable
Inconvenients
  • Pas TypeScript-first
  • Inference de types limitee
  • Moins maintenu recemment
Cas d'usage
  • Projets Formik existants
  • Formulaires simples
  • Migration progressive
Joi
Standard Node.js historique, oriente backend et serveur.
Avantages
  • Tres complet en fonctionnalites
  • Documentation exhaustive
  • Large communaute backend
Inconvenients
  • Pas de support TypeScript natif
  • Lourd pour le frontend
  • Non concu pour le navigateur
Cas d'usage
  • APIs Node.js pures
  • Projets backend existants
  • Validation server-only
io-ts
Approche fonctionnelle pure, compose avec fp-ts.
Avantages
  • Types bidirectionnels (encode/decode)
  • Approche fonctionnelle pure
  • Composition avancee
Inconvenients
  • Courbe apprentissage fp-ts
  • Tres verbeux
  • Ecosysteme de niche
Cas d'usage
  • Projets fp-ts existants
  • Serialisation/deserialisation
  • Equipes FP experimentees

Quand choisir quoi ?

Zod : choix par defaut pour tout projet TypeScript moderne. L'ecosysteme (tRPC, RHF, shadcn) et la DX compensent largement les limites de performance.
Valibot : si le bundle size est une contrainte dure (SPA, edge functions). Migration facile depuis Zod.
ArkType : pour les hot paths ou chaque milliseconde compte (APIs haute charge, batch processing).
Yup/Joi : seulement si vous avez un projet existant qui les utilise deja. Pour un nouveau projet, preferez Zod ou Valibot.
Optimisations·Section 8/13

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
probleme-env-vars.tstypescript
1// Sans validation : bombe a retardement
2const apiKey = process.env.API_KEY; // string | undefined
3const port = process.env.PORT; // string | undefined, jamais number !
4
5// Ce code peut tourner pendant des heures avant de crash
6// quand il essaie enfin d'utiliser apiKey
7fetch(`https://api.example.com?key=${apiKey}`);
8// Si API_KEY est undefined -> URL invalide, erreur opaque
9
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
lib/env.tstypescript
1// lib/env.ts -- valider au demarrage, importer partout
2import { z } from 'zod';
3
4const EnvSchema = z.object({
5 // Base
6 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
7 PORT: z.coerce.number().int().positive().default(3000),
8
9 // Base de donnees
10 DATABASE_URL: z.string().url('DATABASE_URL doit etre une URL valide'),
11
12 // Authentication
13 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 externes
17 STRIPE_SECRET_KEY: z.string().startsWith('sk_', 'Cle Stripe invalide'),
18 STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
19
20 // Email
21 SMTP_HOST: z.string().optional(),
22 SMTP_PORT: z.coerce.number().optional(),
23 SMTP_USER: z.string().optional(),
24
25 // Feature flags
26 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 demarrage
31export const env = EnvSchema.parse(process.env);
32
33// Type infere automatiquement
34export 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)
env-client-server.tstypescript
1// Pour Next.js : separer variables client et serveur
2
3// lib/env-server.ts -- variables serveur uniquement
4const 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});
env-t3.tstypescript
1// Alternative avec t3-env (integration Next.js)
2// npm install @t3-oss/env-nextjs
3
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.js
17 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 client
27// - Validation automatique au build
28// - Erreur claire si une variable manque
Bonnes Pratiques·Section 9/13

Comment 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
contact-form.tsxtsx
1// npm install react-hook-form @hookform/resolvers zod
2
3import { useForm } from 'react-hook-form';
4import { zodResolver } from '@hookform/resolvers/zod';
5import { z } from 'zod';
6
7// 1. Definir le schema
8const 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 type
25type ContactForm = z.infer<typeof ContactFormSchema>;
26
27// 3. Utiliser dans le composant
28export 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 type
42 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.

signup-form-schema.tstypescript
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 champs
21type 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 champ
27});
28
29// errors.confirmPassword?.message affiche :
30// "Les mots de passe ne correspondent pas"
31// grace au path: ['confirmPassword'] dans le refine
invoice-form.tsxtsx
1// Formulaire dynamique avec useFieldArray
2import { 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 article
50 </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.

Bonnes Pratiques·Section 10/13

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
app/actions/create-post.tstypescript
1// Pattern de base : Server Action avec Zod
2'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 donnees
24 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 invalide
32 if (!result.success) {
33 return {
34 success: false,
35 errors: result.error.flatten().fieldErrors,
36 };
37 }
38
39 // 3. Les donnees sont validees et typees
40 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.

components/create-post-form.tsxtsx
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 immediatement
46 </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.

pattern-schema-partage.tstypescript
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 Action
15'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 -- Client
25'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 desynchronisation
Bonnes Pratiques·Section 11/13

Comment 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 unknown en realite
  • Les query params sont toujours des strings
  • safeParse retourne des erreurs structurees pour une reponse 400 propre
app/api/users/route.tstypescript
1// app/api/users/route.ts -- Next.js Route Handler
2import { 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 body
13 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 Zod
24 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 validees
37 const user = await db.user.create({ data: result.data });
38
39 return NextResponse.json(user, { status: 201 });
40}
api-query-params.tstypescript
1// Valider les query params
2// GET /api/users?page=2&limit=50&role=admin
3
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?: string
24 // Les strings ont ete converties en nombres grace a z.coerce
25
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.

validate-external-api.tstypescript
1// Valider les reponses d'API externes
2const 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 immediatement
24 return GitHubUserSchema.parse(data);
25}
26
27// Variante avec safeParse pour une gestion plus fine
28async 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}
trpc-integration.tstypescript
1// Integration tRPC -- validation de bout en bout
2import { initTRPC } from '@trpc/server';
3import { z } from 'zod';
4
5const t = initTRPC.create();
6
7const appRouter = t.router({
8 // Input valide automatiquement par Zod
9 getUser: t.procedure
10 .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.procedure
20 .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 automatiquement
27 return await db.user.create({ data: input });
28 }),
29});
30
31// Cote client, les types sont inferes automatiquement
32// 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.

Avance·Section 12/13

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.

recursive-schema.tstypescript
1import { z } from 'zod';
2
3// Arbre de fichiers recursif
4type 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 complete
17FileNodeSchema.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});
recursive-comments-menu.tstypescript
1// Commentaires avec reponses imbriquees
2type 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 recursif
19type 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.

branded-types.tstypescript
1// Sans branded types : rien n'empeche de melanger les IDs
2function 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 compilation
8const 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); // OK
21getUserSafe(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 utiles
26const 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.

schema-factory.tstypescript
1// Schema factory : reponse paginee generique
2function 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// Utilisation
15const 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 generique
22function 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.

custom-preprocess.tstypescript
1// z.custom() -- validation entierement custom
2const FileSchema = z.custom<File>(
3 (val) => val instanceof File,
4 { message: 'Un fichier est requis' }
5);
6
7// Schema avec validation custom complexe
8const 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 max
13 return true;
14 },
15 { message: 'Image JPEG, PNG ou WebP de 5 MB maximum' }
16);
17
18// z.preprocess() -- transformer AVANT la validation
19const 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'); // 42
28FlexibleNumberSchema.parse(42); // 42
29FlexibleNumberSchema.parse('abc'); // ZodError (NaN n'est pas positif)
30
31// Discriminated union pour state machine
32const 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 automatiquement
Avance·Section 13/13

Que 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
ecosystem-examples.tstypescript
1// zod-form-data : parser les FormData directement
2import { 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 Action
11export async function uploadFile(formData: FormData) {
12 const result = UploadSchema.safeParse(formData);
13 // result.data.file -> File
14 // result.data.title -> string
15 // result.data.tags -> string[]
16}
17
18// ---
19
20// zod-i18n-map : messages multilingues
21import { 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 francais
33z.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.

Zod v3
Version stable largement adoptee. La plus utilisee en production.
Avantages
  • Ecosysteme tres mature
  • Documentation exhaustive
  • Compatible avec tous les outils actuels
  • Tres stable en production
Inconvenients
  • Performance lente (< 1M ops/sec)
  • Bundle size ~33 KB
  • Pas de JSON Schema natif
  • Pas de Standard Schema
Cas d'usage
  • Projets en production stable
  • Migration progressive vers v4
Zod v4
Version nouvelle generation avec gains majeurs de performance et features.
Avantages
  • 14x plus rapide sur les strings
  • Core 57% plus petit (~17 KB)
  • .toJSONSchema() natif
  • Standard Schema compatible
  • Discriminated unions composables
Inconvenients
  • Breaking changes a migrer
  • Certains plugins pas encore compatibles
  • Moins de retours production
Cas d'usage
  • Nouveaux projets
  • Projets qui necessitent JSON Schema
  • Besoin de meilleures performances
migration-v3-v4.tstypescript
1// Changements cles v3 -> v4
2
3// 1. .toJSONSchema() -- natif en v4
4import { 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 leger
24import { z } from '@zod/mini';
25// ~1.9 KB gzipped, ideal pour le frontend
26
27// 3. Discriminated unions composables
28const 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 union
34const 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.

standard-schema.tstypescript
1// Standard Schema : interoperabilite entre librairies
2// Zod v4, Valibot et ArkType implementent la spec Standard Schema
3
4// Avec Standard Schema, les outils n'ont plus besoin de resolvers specifiques
5// React Hook Form pourra accepter n'importe quelle librairie compatible
6
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'integration
23
24// Zod v4 est Standard Schema compatible par defaut
25// Pas de configuration supplementaire

En 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.