Valider les données avec Zod

Disponible dans 4 jours

Devenir premium
Résumé Support

Zod est une bibliothèque de validation de données pour JavaScript et TypeScript. On définit un schéma qui décrit la forme attendue des données, puis on l'utilise pour vérifier leur contenu. Ce même schéma permet aussi de générer des types TypeScript et de transformer les valeurs, par exemple pour supprimer les espaces autour d'une chaîne ou convertir une chaîne en nombre.

Sommaire

Premier schéma

Pour commencer, on installe la bibliothèque avec bun add zod. On importe ensuite z pour construire notre schéma.

import { z } from "zod"; const schema = z.object({ firstName: z.string(), lastName: z.string(), });

Ensuite on peut utiliser ce schéma pour parser les données.

const result = schema.parse({ firstName: "John", lastName: "Doe", });

La méthode parse() renvoie les données validées. Si une propriété attendue manque ou possède un mauvais type, elle lève une exception ZodError qui contient le détail des problèmes rencontrés.

Si on ne souhaite pas lever d'erreur en cas de problème on peut utiliser safeParse() qui renverra un objet avec une propriété success qui nous indiquera si les données satisfont le schéma :

const result = schema.safeParse({ firstName: "John", lastName: "Doe", }); if (result.success) { console.log(result.data); } else { console.log(result.error); }

On une propriété data lorsque la validation fonctionne, et une propriété error dans le cas contraire. Enfin, La méthode validate() permet aussi de vérifier les données en obtenant simplement un booléen.

Par défaut, z.object() retire du résultat les propriétés qui ne figurent pas dans le schéma. Un âge envoyé en plus du prénom et du nom ne déclenche donc pas d'erreur, mais ne se retrouve pas dans les données retournées. Si on souhaite refuser les propriétés supplémentaires, on utilise z.strictObject().

On peut compléter les types avec des contraintes et personnaliser les messages d'erreur grâce à l'option error :

const schema = z.object({ firstName: z.string(), lastName: z.string(), age: z.coerce.number().min(18, { error: "Vous devez être majeur" }).max(120), notes: z.array(z.number()).optional(), });

Ici, l'âge doit être compris entre 18 et 120, et les notes doivent former un tableau de nombres. L'utilisation de z.coerce.number() permet de convertir la valeur avec la fonction JavaScript Number avant de la valider. C'est utile pour les formulaires qui envoient souvent des chaînes : la valeur '19' devient ainsi le nombre 19. Sans coercition, z.number() refuserait cette chaîne.

Générer les types

Le résultat de parse() est automatiquement typé à partir du schéma. On peut aussi récupérer ces types pour les utiliser ailleurs, par exemple dans les paramètres d'une fonction :

type Input = z.input<typeof schema>; type Output = z.output<typeof schema>;

Il faut distinguer les données acceptées en entrée de celles obtenues en sortie. Avec z.coerce.number(), le type d'entrée est par défaut unknown, alors que le type de sortie est number puisque la conversion a déjà eu lieu.

On peut préciser le type d'entrée de la coercition avec un générique :

const ageSchema = z.coerce.number<string | number>(); type AgeInput = z.input<typeof ageSchema>; // string | number type AgeOutput = z.output<typeof ageSchema>; // number

On conserve ainsi une seule définition du schéma pour valider les données à l'exécution et obtenir les types utilisés par TypeScript.

Nullable & Optional

Une propriété est requise par défaut. Pour accepter son absence, on ajoute .optional(). Si elle est fournie, elle reste soumise à la validation du schéma.

const schema = z.object({ firstName: z.string(), lastName: z.string().nullable(), notes: z.array(z.number()).optional(), });

Et il existe aussi les méthodes suivantes :

  • .optional() accepte l'absence de valeur ou undefined.
  • .nullable() accepte la valeur null.
  • .nullish() combine les deux et accepte null, undefined ou l'absence de propriété.
  • .default(XX) pour définir une valeur par défaut dans le cas des valeurs optionnelles.

Transform

Les transformations permettent de modifier les données après leur validation. Certaines sont déjà prévues, comme .trim() pour supprimer les espaces au début et à la fin d'une chaîne. Mais on peut aussi définir nos propre transformation à l'aide de .transform() :

// On souhaite convertir les chaine vide "" en null const lastNameSchema = z.string().transform((value) => (value === "" ? null : value));

L'ordre des opérations compte. Si on impose une longueur minimale avant la transformation, la chaîne vide sera rejetée avant de pouvoir devenir null. Pour transformer la valeur puis la valider avec un autre schéma, on utilise .pipe() :

const lastNameSchema = z .string() .transform((value) => (value === "" ? null : value)) .pipe(z.string().min(3).nullable());

Une chaîne vide devient null et reste valide. Un nom renseigné doit en revanche contenir au moins trois caractères. Pour ce type de traitement en amont, on dispose aussi de z.preprocess() :

const lastNameSchema = z.preprocess( (value: string | null) => (value === "" ? null : value), z.string().min(3).nullable(), );

Le premier paramètre transforme les données et le second définit le schéma qui les valide ensuite. Sans annotation sur le paramètre de la fonction, le type d'entrée est unknown. Ici, on précise string | null pour obtenir un type d'entrée plus explicite.

Refine

La méthode .refine() ajoute une règle de validation personnalisée. Elle reçoit une fonction qui retourne true lorsque la valeur est valide, ou false dans le cas contraire. Contrairement à .transform(), elle sert à ajouter une contrainte, pas à modifier la donnée.

On peut par exemple vérifier qu'un identifiant de parent correspond à un utilisateur existant en interrogeant une API :

const schema = z.object({ parentId: z.number().refine( async (parentId) => { const response = await fetch(`https://jsonplaceholder.typicode.com/users/${parentId}`); const user = (await response.json()) as { id?: number }; return user.id === parentId; }, { error: "Cet utilisateur n'existe pas" }, ), }); const result = await schema.parseAsync({ parentId: 1 });

En revanche, comme cette règle est asynchrone, il faut utiliser parseAsync() ou safeParseAsync() et attendre le résultat. Une validation synchrone avec parse() ne peut pas traiter cette promesse.

Pour réutiliser ce type de vérification, on peut extraire la logique dans une fonction afin d'éviter la répétition. Pour des validations plus poussées, .superRefine() donne accès à un contexte qui permet d'ajouter manuellement des problèmes de validation (pratique pour représenter plusieurs types d'erreurs possible au sein d'une seule vérification).

Combiner les schéma

On peut construire de nouveaux schémas à partir de schémas existants pour éviter de répéter les mêmes propriétés :

const userSchema = z.object({ firstName: z.string(), lastName: z.string(), }); const userWithAgeSchema = userSchema.extend({ age: z.number(), }); const withoutFirstNameSchema = userSchema.omit({ firstName: true }); const firstNameOnlySchema = userSchema.pick({ firstName: true }); const updateUserSchema = userSchema.partial();

La méthode .extend() reprend les propriétés du schéma et permet d'en ajouter. Avec .omit(), on retire les clés indiquées, tandis que .pick() conserve uniquement les clés sélectionnées. Dans les deux cas, on passe un objet avec la valeur true pour les propriétés concernées.

Enfin, .partial() rend toutes les propriétés optionnelles. C'est pratique pour distinguer la création d'un utilisateur, où l'on attend tous les champs, de sa modification, où l'on souhaite accepter seulement les informations à changer.

Union

Une union permet d'accepter plusieurs formes de données. Par exemple, un résultat peut contenir des données en cas de succès ou un message en cas d'erreur :

const successSchema = z.object({ type: z.literal("success"), data: z.number(), }); const errorSchema = z.object({ type: z.literal("error"), error: z.string(), }); const resultSchema = z.union([successSchema, errorSchema]);

La méthode z.literal() impose une valeur précise. Le type TypeScript généré correspond alors à l'union des deux formes. L'inconvénient d'une union classique est que ses erreurs peuvent être difficiles à exploiter : on sait que les données ne correspondent à aucune des possibilités, mais le problème est moins facile à localiser.

Lorsqu'une propriété permet de distinguer les cas, on peut utiliser une union discriminée :

const resultSchema = z.discriminatedUnion("type", [successSchema, errorSchema]);

Le type TypeScript obtenu reste le même, mais Zod peut sélectionner le schéma à partir de la propriété type ce qui permet d'obtenir des erreurs de validation beaucoup plus explicite.

Gérer les erreurs

Une ZodError expose une propriété issues qui contient la liste des problèmes de validation :

try { resultSchema.parse({ type: "error" }); } catch (error) { if (error instanceof z.ZodError) { console.log(error.issues); } }

Chaque problème contient notamment un code, le chemin de la propriété concernée et un message. Selon l'erreur, on retrouve aussi des informations comme le type attendu. Ces données permettent de présenter les erreurs ou de construire ses propres traductions.

Zod propose également des traductions pour ses messages génériques. Pour les afficher en français, on configure la locale avant de valider les données :

z.config(z.locales.fr());

On peut toujours préférer des messages personnalisés directement dans les contraintes, comme avec l'option error utilisée pour l'âge minimum.

L'écosystème

Côté serveur

Zod s'intègre à d'autres bibliothèques pour valider les données au point d'entrée d'une application. Avec Hono par exemple, le paquet @hono/zod-validator permet de vérifier les paramètres d'URL et le corps JSON d'une requête avant d'exécuter la fonction de traitement :

import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; const app = new Hono(); const userSchema = z.object({ firstName: z.string(), lastName: z.string(), }); app.post( "/", zValidator("query", z.object({ search: z.string().optional() })), zValidator("json", userSchema), (c) => { const query = c.req.valid("query"); const user = c.req.valid("json"); return c.json({ search: query.search, user }); }, );

Si les données ne correspondent pas au schéma, une erreur est renvoyée avant d'entrer dans la fonction. Sinon, c.req.valid('json') donne accès aux données validées avec leur type TypeScript inféré. On peut ensuite prévoir un middleware pour mettre en forme les erreurs selon les besoins de l'application.

Côté frontend

On retrouve aussi des intégrations côté frontend, par exemple avec React Hook Form, où un schéma Zod peut servir à valider les données d'un formulaire.

Pour cela, on installe les dépendances avec bun add react-hook-form @hookform/resolvers zod, puis on passe notre schéma à zodResolver dans les options de useForm() :

import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const userSchema = z.object({ firstName: z.string().min(1, { error: "Le prénom est obligatoire" }), age: z.number().min(18, { error: "Vous devez être majeur" }), }); export function UserForm() { const { register, handleSubmit, formState: { errors }, } = useForm<z.output<typeof userSchema>>({ resolver: zodResolver(userSchema), defaultValues: { firstName: "" }, }); return ( <form onSubmit={handleSubmit((user) => console.log(user))} noValidate> <label htmlFor="firstName">Prénom</label> <input id="firstName" {...register("firstName")} /> {errors.firstName && <p role="alert">{errors.firstName.message}</p>} <label htmlFor="age">Âge</label> <input id="age" type="number" {...register("age", { valueAsNumber: true })} /> {errors.age && <p role="alert">{errors.age.message}</p>} <button type="submit">Enregistrer</button> </form> ); }

register() relie les champs au formulaire et handleSubmit() appelle notre fonction si les données sont valides. Les erreurs de Zod sont disponibles dans formState.errors pour être affichées sous les champs. Ici, valueAsNumber convertit la saisie de l'âge en nombre avant la validation par z.number(). L'attribut noValidate désactive la validation native du navigateur pour laisser React Hook Form gérer les messages. Vous pouvez retrouver cette intégration dans la documentation de React Hook Form.

Notions avancées

Transformation bi directionnelle

Les transformations présentées jusqu'ici vont dans un seul sens. Les codecs permettent de définir une transformation dans les deux sens, pour décoder ou encoder des données. C'est une possibilité à connaître si l'on a besoin de retrouver la représentation initiale, même si une transformation classique suffit pour les usages présentés ici.

Zod mini

La syntaxe de Zod repose sur des méthodes que l'on enchaîne sur les schémas. Elle est agréable à utiliser, mais peut rendre l'élimination du code inutilisé, ou tree shaking, plus difficile ce qui fait grandement augmenter la taille du bundle.

Zod Mini propose une syntaxe à base de fonctions que l'on compose, plutôt que de méthodes enchaînées. Par exemple, les helpers z.nullable() et z.optional() entourent un autre schéma. Cette organisation facilite le tree shaking et permet de réduire le poids du code envoyé au navigateur.

Zod Mini est inclus dans le paquet zod et s'importe depuis zod/mini. On peut reprendre notre schéma utilisateur avec cette syntaxe :

import * as z from "zod/mini"; const userSchema = z.object({ firstName: z.string().check(z.minLength(1)), lastName: z.nullable(z.optional(z.string())), age: z.number().check(z.minimum(18), z.maximum(120)), notes: z.optional(z.array(z.number())), });

Les concepts restent les mêmes, mais la syntaxe peut être moins agréable à manipuler. C'est un compromis intéressant lorsque la taille du bundle devient une contrainte, notamment côté frontend.

AOT

La compilation Ahead of Time consiste à compiler un schéma en amont pour rendre les validations suivantes plus efficaces. Si on doit vérifier des centaines de milliers d'objets avec le même schéma, cela peut éviter de répéter le travail d'interprétation à chaque validation.

À partir de Zod 4.5, on peut utiliser z.compile() pour obtenir une version compilée de notre schéma, puis la réutiliser pour les validations suivantes :

import { z } from "zod"; const userSchema = z.object({ firstName: z.string(), lastName: z.string(), age: z.number().min(18).max(120), }); // On compile une seule fois, avant de traiter les données. const compiledUserSchema = z.compile(userSchema); const users = [ { firstName: "John", lastName: "Doe", age: 19 }, { firstName: "Jane", lastName: "Doe", age: 25 }, ]; for (const user of users) { const result = compiledUserSchema.safeParse(user); if (result.success) { console.log(result.data); } else { console.log(result.error.issues); } }

Cette approche concerne surtout les traitements volumineux côté backend : le compilateur prend lui-même de la place et n'est pas nécessaire pour les usages courants. C'est une piste à explorer si la performance de la validation des données devient un point de blocage et que l'on réutilise toujours le même schéma.