Zod 4, validation par schémas et migration depuis la v3

Zod décrit la forme des données et en déduit les types : la version 4, la variante mini pour réduire le bundle, et les changements silencieux à surveiller.

L'équipe CodeWorlds15 min de lecture ·

Pratique JavaScript et TypeScript dans des leçons interactives avec un éditeur de code.

Crée un compte gratuit→
Dans cet article11

Zod décrit la forme des données et vérifie si ce qui arrive de l'extérieur a bien cette forme. Au passage, il déduit un type de cette description, si bien qu'un même schéma joue deux rôles à la fois : il protège à l'exécution et type à la compilation.

Cette combinaison résout un problème que les types seuls ne résolvent pas. Une interface qui décrit une réponse de serveur est une déclaration que le compilateur croit sur parole. Un schéma est une vérification qui passe ou échoue, et le type en découle automatiquement, sans jamais pouvoir s'en écarter.

L'agencement de base

TypeScript
import { z } from 'zod'

const User = z.object({
  id: z.uuid(),
  email: z.email(),
  age: z.number().int().min(18),
  role: z.enum(['admin', 'editor', 'reader']),
})

type User = z.infer<typeof User>

const result = User.safeParse(dataFromServer)
if (!result.success) {
  console.error(result.error.issues)
} else {
  result.data.email
}

Deux choses dans ce code comptent plus que le reste.

La première, c'est z.infer, qui déduit un type à partir du schéma. Ça donne une seule source de vérité au lieu de deux, et un changement de schéma fait immédiatement apparaître des erreurs partout où le code utilisait l'ancienne forme.

La seconde, c'est safeParse plutôt que parse. Le premier retourne un résultat à vérifier, le second lève une exception. Pour des données fournies par l'utilisateur, tu veux presque toujours la première option, car un formulaire invalide n'est pas une situation exceptionnelle, mais un cas ordinaire.

Là où cette bibliothèque gagne vraiment sa place

Ça vaut le coup de nommer les endroits où ça paie, car ajouter des schémas partout est un coût sans retour.

La frontière du système est le bon endroit : une réponse d'une interface externe, le corps d'une requête entrante, des données de formulaire, des variables d'environnement au démarrage, le contenu d'un fichier de configuration. Dans tous ces cas, les données viennent de l'extérieur de TypeScript, et un type écrit à la main n'est rien d'autre qu'un vœu pieux.

Les variables d'environnement méritent une phrase à elles seules, car c'est le gain le moins cher qui soit. Un schéma vérifié au démarrage fait qu'une clé manquante arrête l'application immédiatement, avec un message lisible, plutôt que de tout casser à trois heures du matin à la première requête qui en avait besoin.

TypeScript
import { z } from 'zod'

const Environment = z.object({
  DATABASE_URL: z.url(),
  STRIPE_SECRET: z.string().startsWith('sk_'),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  NODE_ENV: z.enum(['development', 'test', 'production'])
})

export const env = Environment.parse(process.env)

La conversion forcée sur le port compte, car les variables d'environnement arrivent toujours sous forme de chaînes de caractères. Sans elle, un schéma qui attend un nombre rejette une valeur pourtant valide, et le message parle du type plutôt que de la cause réelle.

L'intérieur d'une application est le mauvais endroit. Les données qui ont passé la validation à la frontière portent déjà un type, et les revérifier entre les fonctions ajoute du code et du temps sans aucun bénéfice.

Version 4 et ce qui a changé

La quatrième version est sortie en 2025, et ce qui compte dans cette version, ce ne sont pas de nouvelles capacités, mais trois changements de comportement et un changement dans la forme du paquet.

La méthode qui fusionne des schémas est désormais déconseillée, au profit de l'extension ou de l'étalement de la forme. Il vaut la peine de souligner qu'elle n'a pas disparu : .merge() fonctionne toujours et porte seulement une marque de dépréciation, donc le compilateur ne bloque rien, et un éditeur barre tout au plus le nom. Les auteurs donnent deux raisons à ce changement : l'extension ne perd pas les réglages concernant les champs excédentaires, et elle se vérifie plus vite dans le compilateur.

TypeScript
const Base = z.object({ id: z.uuid() })
const Stamp = z.object({ createdAt: z.iso.datetime() })

const Record = Base.extend(Stamp.shape)

Étaler la forme du second schéma fonctionne aussi quand des champs se recoupent : c'est la version de droite qui l'emporte. Ce comportement vaut la peine d'être connu, car remplacer silencieusement le type d'un champ en fusionnant deux schémas est une erreur que le compilateur ne signalera pas.

La gestion des erreurs a été unifiée : au lieu de plusieurs paramètres séparés, il n'y en a plus qu'un seul, partagé pour tout le schéma. Le nombre de types de problèmes signalés est passé de quinze à onze, ce qui simplifie le code de traitement des erreurs tout en demandant de le relire.

Le troisième changement est le plus dangereux, car il compile sans un mot. Il concerne les valeurs par défaut et comporte deux parties distinctes, faciles à confondre l'une avec l'autre.

La première concerne les champs optionnels. Une valeur par défaut écrite dans un champ marqué comme optionnel s'applique désormais même quand la clé était totalement absente de l'entrée.

TypeScript
const Settings = z.object({
  theme: z.string().default('light').optional()
})

Settings.parse({})

Dans la version précédente, cet appel retournait un objet vide ; dans la quatrième, il retourne un objet avec le champ réglé sur sa valeur par défaut. Un code qui vérifie la présence d'une clé plutôt que sa valeur va donc commencer à voir un champ qu'il ne voyait jamais auparavant.

La seconde partie concerne les transformations. Sur une entrée indéfinie, la validation retourne désormais la valeur par défaut immédiatement, sans la faire passer par le schéma : cette valeur doit donc correspondre au type de sortie, pas au type d'entrée. Le schéma z.string().transform((v) => v.length).default(0) retourne zéro, alors que son équivalent dans la version précédente, avec une valeur par défaut 'tuna', retournait quatre. La nouvelle méthode .prefault(), pour « valeur par défaut avant traitement », restaure l'ancien comportement.

Ce sont les endroits à relire à la main pendant une migration, car aucun terme de recherche ne les trouvera.

À cela s'ajoute une exigence de réglages stricts du compilateur et d'une version plus récente de TypeScript : un projet avec des réglages permissifs doit donc d'abord les resserrer.

Trois variantes dans un seul paquet

La forme du paquet est inhabituelle aujourd'hui, et c'est justement ce qui facilite le plus la migration.

La variante de base est l'interface habituelle avec des méthodes chaînées. La variante mini expose les mêmes validateurs sous forme de fonctions autonomes, ce qui permet à un outil de build d'éliminer tout ce que tu n'as pas utilisé. La troisième variante est la version précédente, disponible sous un chemin d'import séparé.

Ce dernier point compte plus qu'il n'y paraît. Il permet aux deux versions de tourner côte à côte dans un même projet, si bien que la migration avance fichier par fichier plutôt qu'en un seul passage sur tout le dépôt.

TypeScript
import { z } from 'zod'
import { z as z3 } from 'zod/v3'
import * as zm from 'zod/mini'

const Current = z.object({ id: z.uuid() })
const Legacy = z3.object({ id: z3.string().uuid() })
const Light = zm.object({ id: zm.string() })

Sur un gros projet, c'est la différence entre une semaine et un après-midi. La variante légère mérite d'être envisagée dans le code envoyé au navigateur, puisqu'elle permet au bundler d'éliminer les validateurs que tu n'as jamais utilisés.

La variante mini mérite d'être envisagée dans le code envoyé à un navigateur. Le cœur de la bibliothèque lui-même a rétréci de plus de moitié dans cette version, et la variante fonctionnelle ajoute par-dessus la possibilité d'éliminer les validateurs inutilisés. Côté serveur, ça ne change rien, et c'est là que la forme chaînée l'emporte par commodité.

Transformations et schémas à double sens

Un schéma ne sert pas qu'à vérifier. Il peut aussi transformer, et cette capacité fait économiser pas mal de code aux frontières du système.

TypeScript
const Order = z.object({
  createdAt: z.string().transform((s) => new Date(s)),
  amountCents: z.number().int(),
}).transform((o) => ({
  createdAt: o.createdAt,
  amount: o.amountCents / 100,
}))

Après une telle transformation, le type d'entrée et le type de sortie diffèrent, et la bibliothèque les distingue avec des utilitaires séparés. Cette distinction prête à confusion jusqu'au jour où tu tombes sur un cas où elle compte : la description des données envoyées à un serveur a besoin du type d'entrée, tandis que le code qui travaille sur le résultat a besoin du type de sortie.

L'usage typique, c'est de convertir des chaînes de caractères en dates et en nombres. La réponse d'une interface arrive sous forme textuelle, car c'est ainsi que fonctionne la sérialisation, et la convertir une fois à la frontière vaut mieux que la convertir à quinze endroits.

Méfie-toi, en revanche, des transformations qui changent le sens des données. Un schéma qui arrondit des montants ou tronque des chaînes de caractères en même temps qu'il valide cache de la logique métier là où personne ne va la chercher. Vérifier et calculer sont deux responsabilités distinctes, et les mélanger se paie au premier bug arithmétique.

Les vérifications entre plusieurs champs sont une capacité à part. Une date de fin postérieure à une date de début, ou un mot de passe qui correspond à sa confirmation, sont des règles qui portent sur l'objet entier plutôt que sur un seul champ, et s'expriment par une vérification supplémentaire au niveau de l'objet, accompagnée d'une indication du champ auquel rattacher le message.

Messages d'erreur et multilinguisme

Les messages par défaut sont en anglais et techniques, donc les montrer directement à un utilisateur est une erreur qu'on voit dans chaque application construite à la hâte.

Il y a trois façons de faire. Tu peux fournir un message personnalisé pour chaque règle, ce qui donne un contrôle total et demande le plus de texte à écrire. Tu peux régler un seul message pour tout un schéma, ce qui suffit pour les champs où le seul enjeu est qu'un champ est requis. Tu peux aussi remplacer les messages globalement via le mécanisme de traduction intégré, la bonne approche pour une application multilingue.

Cette dernière option est souvent ignorée, car elle demande une configuration ponctuelle qui fonctionne ensuite partout. Mets-la en place au début d'un projet, avant qu'une centaine de messages écrits à la main n'apparaissent dans le code, des messages que personne ne traduira plus tard.

Dans les formulaires React, les messages reviennent liés à un chemin vers le champ, donc les afficher à côté du bon champ ne demande aucun mapping. Dans les structures imbriquées, ce chemin est un tableau plutôt qu'une chaîne de caractères, et ce détail fait trébucher la plupart des gens sur leur premier formulaire avec une liste d'éléments.

Zod face aux alternatives

SolutionTailleÉcritureQuand la choisir
ZodMoyenne, mini bien plus petiteMéthodes chaînéesLe choix par défaut dans un projet TypeScript
ValibotTrès petiteFonctions composéesCode dans le navigateur, chaque kilo-octet compte
Bibliothèques à base de classesGrandeDécorateursUn backend avec injection de dépendances
Vérification manuelleZéroCode ordinaireUn seul champ, un seul format

La deuxième ligne est le concurrent le plus sérieux, et l'écart avec la variante mini s'est suffisamment réduit pour que, sur un nouveau projet, ça vaille le coup de mesurer les deux sur ton propre code plutôt que de faire confiance aux comparatifs.

La dernière ligne mérite d'être prise au sérieux. Vérifier qu'un champ contient un nombre supérieur à zéro ne demande aucune bibliothèque, et un schéma qui décrit un formulaire de trois champs est souvent plus long que le code qu'il remplace. La valeur apparaît avec les types déduits et les structures imbriquées.

Unions discriminées et formes conditionnelles

Un objet avec un ensemble fixe de champs est le cas le plus simple, et la plupart des données réelles ne sont pas comme ça.

La réponse d'une interface est soit un résultat, soit une erreur. Un événement porte des champs différents selon son type. Un formulaire affiche des champs différents une fois qu'une option est choisie. Dans tous ces cas, décrire les choses avec un seul objet plein de champs optionnels est un mensonge, car ça autorise des combinaisons qui n'ont aucune raison d'exister.

Le bon outil, c'est une union discriminée par un seul champ. Tu nommes le champ décisif et tu listes les variantes, et la bibliothèque ne vérifie que la variante qui correspond. Le gain est double : la vérification tourne plus vite, car elle n'essaie pas toutes les possibilités une par une, et le message d'erreur pointe vers le problème précis plutôt que de renvoyer les échecs de toutes les variantes à la fois.

Le type déduit est quant à lui une union discriminée au sens du compilateur, donc une fois le champ décisif vérifié, les champs restants se réduisent tout seuls. C'est le même mécanisme qui évite les transtypages dans le code qui traite la réponse.

Retiens une chose à propos des structures récursives, un arbre de catégories par exemple. Le type doit être déclaré à la main et transmis à la bibliothèque, car la déduction automatique ne peut pas résoudre une référence à elle-même. C'est l'un des rares endroits où une annotation manuelle est nécessaire plutôt que redondante.

Travailler avec le reste de la pile

La plus grande force de cette bibliothèque vient du fait que d'autres outils savent lire ce qu'elle produit.

Avec les formulaires, un schéma sert à la fois à vérifier les champs et à typer les valeurs, donc tu ne déclares jamais la forme deux fois. Les messages d'erreur reviennent liés à des champs précis, ce qui permet de les afficher à côté des bons champs sans mapping manuel.

Avec tRPC, le schéma d'entrée d'une procédure joue un double rôle : il rejette les données invalides et déduit le type d'argument que voit le client. C'est le même mécanisme que pour les formulaires, étiré à travers le réseau.

Quand tu travailles avec des modèles de langage, un schéma décrit la structure de réponse attendue et permet de rejeter une sortie qui ne s'y conforme pas. C'est là que la validation à l'exécution devient indispensable, car un modèle peut renvoyer quelque chose presque correct, et un type ne le détectera pas.

Note aussi qu'un schéma peut être converti vers une description dans un format standard, et inversement. Pour générer de la documentation d'interface ou travailler avec des outils hors de cet écosystème, ça évite de maintenir deux descriptions de la même chose.

Erreurs courantes

La première, c'est d'utiliser parse plutôt que safeParse sur des données fournies par l'utilisateur. Une exception levée transforme un formulaire invalide en erreur serveur.

La seconde, c'est de valider les mêmes données de façon répétée à l'intérieur de l'application. Après une vérification à la frontière, les données portent un type, et les revérifier est un coût sans retour.

La troisième, c'est de sauter la validation des variables d'environnement. Une clé manquante ne se révèle alors qu'à la première requête qui en avait besoin, plutôt qu'au démarrage.

La quatrième, c'est de rater le changement de comportement des valeurs par défaut dans la version quatre. Le code compile sans avertissement et se comporte différemment.

La cinquième, c'est de décrire l'intérieur d'une application avec des schémas au lieu de ses frontières. Le code et le temps d'exécution augmentent, sans que la sécurité augmente.

La sixième, c'est d'importer la variante complète dans du code envoyé à un navigateur quand la variante fonctionnelle suffirait. Sur un formulaire simple, la différence de taille du bundle se remarque.

La septième, c'est de décrire des données à forme variable avec un seul objet plein de champs optionnels. Le type autorise alors des combinaisons qui ne peuvent pas se produire, et le code doit soit les gérer, soit faire comme si elles n'existaient pas.

FAQ

En quoi Zod 4 diffère-t-il de la version précédente ?

Par trois changements de comportement et une nouvelle forme de paquet. La méthode de fusion de schémas a été marquée comme dépréciée au profit de l'extension, même si elle fonctionne toujours, la gestion des erreurs a été unifiée, et les valeurs par défaut s'appliquent désormais aussi dans les champs optionnels et reviennent sans passer par le schéma. Le cœur de la bibliothèque est par ailleurs plus de deux fois plus petit.

Comment migrer sans interruption de service ?

Fichier par fichier. La version précédente est disponible sous un chemin d'import séparé, donc les deux peuvent tourner côte à côte dans un même projet. Porte le plus d'attention aux endroits avec des valeurs par défaut, car ce changement ne se manifestera pas par une erreur de compilation.

Qu'est-ce que la variante mini ?

Le même ensemble de validateurs, exposé sous forme de fonctions autonomes plutôt que de méthodes chaînées. Ça permet à un outil de build de supprimer tout ce que tu n'as pas utilisé, donc ça paie dans le code envoyé à un navigateur. Côté serveur, la variante de base est plus pratique.

Est-ce que Zod ralentit une application ?

Vérifier coûte du temps, mais ça tourne aux frontières du système, donc généralement une fois par requête. Le problème apparaît quand tu valides les mêmes données de façon répétée à l'intérieur de l'application, ou quand tu vérifies de très grands tableaux dans une boucle de traitement de requête.

Ai-je besoin de Zod si j'ai déjà des types ?

Oui, car les types disparaissent à la compilation. Une réponse de serveur décrite comme un objet d'une forme donnée peut en réalité être n'importe quoi, et le compilateur le croira. La validation à l'exécution est la seule vérification qui se produise réellement.

La documentation complète se trouve sur le site du projet, et les changements de la quatrième version dans les notes de version.

À lire aussi