Valider les données runtime en TypeScript avec Zod
1. Vue d’ensemble
Les types de TypeScript sont effacés après la compilation. Ils ne vous protègent donc pas contre un JSON mal formaté provenant d’une API, d’un formulaire soumis par l’utilisateur ou de variables d’environnement manquantes.
Cet article vous guide pour utiliser Zod 4 afin de valider les données au runtime. Vous déclarerez un schéma une seule fois et en déduirez le type. Vous apprendrez aussi comment gérer proprement les erreurs, écrire des tests et éviter quelques pièges courants.
2. Pourquoi les types TypeScript ne suffisent pas au runtime
Considérez ce code familier :
interface Customer {
id: number;
email: string;
}
const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;
console.log(customer.id + 1);
console.log(customer.email.toLowerCase());
JSON.parse retourne any. L’instruction as Customer n’est qu’une promesse au compilateur, il n’y a pas de vérification réelle. Le résultat d’exécution :
421
TypeError: Cannot read properties of null (reading 'toLowerCase')
id est la chaîne "42" donc l’addition devient une concaténation. email est null donc le programme plante. Le compilateur ne signale rien. Vous avez besoin d’une couche de vérification qui s’exécute réellement au runtime. Zod fait exactement ce travail.
3. Installation
Zod n’a pas d’autres dépendances et s’exécute aussi bien sur Node.js que dans le navigateur :
npm install zod
Pour que Zod déduise les types correctement, vous devez activer le mode strict dans tsconfig.json :
{
"compilerOptions": {
"strict": true
}
}
Remarque : Sans
"strict": true, les champs optionnels peuvent être mal déduits. Vous perdrez la plupart des avantages en termes de typage.
4. Définir un schéma et déduire les types
Un schéma décrit la forme des données que vous attendez :
import { z } from "zod";
const CustomerSchema = z.object({
id: z.number().int().positive(),
email: z.email(),
fullName: z.string().min(2).max(100),
phone: z.string().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});
type Customer = z.infer<typeof CustomerSchema>;
Voici ce que chaque partie fait :
z.number().int().positive()exige un nombre entier positif.z.email()vérifie si la chaîne a le bon format email. Dans Zod 4, les formats de chaîne comme email, URL, UUID sont déclarés au niveau supérieur..optional()permet au champ d’être absent ou d’avoir la valeurundefined..default("FREE")remplit automatiquement la valeur lorsque l’entrée n’a pas de champtier.z.infergénère le type TypeScript à partir du schéma.
En survolant Customer, vous verrez le type équivalent :
type Customer = {
id: number;
email: string;
fullName: string;
phone?: string | undefined;
tier: "FREE" | "PRO" | "ENTERPRISE";
}
La force réside ici : le schéma est la source unique de vérité. Quand vous ajoutez un champ au schéma, le type se met à jour automatiquement. Vous n’avez pas besoin de maintenir une interface en parallèle.
5. parse et safeParse
Zod a deux façons principales de valider.
5.1. parse : lève une exception en cas d’erreur
const customer = CustomerSchema.parse({
id: 42,
email: "an.nguyen@example.com",
fullName: "Nguyễn Văn An",
});
console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }
La valeur retournée a été vérifiée et a le type Customer. Le champ tier est rempli par défaut. Si les données sont incorrectes, parse lève ZodError.
5.2. safeParse : retourne le résultat, ne lève pas d’erreur
const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]
result est une union discriminée. Une fois que vous vérifiez result.success, TypeScript sait avec certitude quelle branche a data et laquelle a error. Vous remarquez aussi que Zod ne s’arrête pas à la première erreur mais collecte toutes les erreurs. C’est très utile pour afficher des erreurs dans un formulaire.
Une règle de sélection simple :
- Utilisez
safeParseaux limites du système, par exemple le corps de la requête ou un formulaire, où les erreurs sont normales. - Utilisez
parsequand des données incorrectes signifient un bug ou une configuration cassée et que l’application doit s’arrêter immédiatement.
6. Messages d’erreur personnalisés et formatage des erreurs
Les messages d’erreur par défaut de Zod sont en anglais et assez techniques. Vous pouvez passer un paramètre error pour les remplacer :
const SignUpSchema = z.object({
email: z.email({ error: "Email invalide" }),
password: z.string().min(8, { error: "Le mot de passe doit contenir au moins 8 caractères" }),
});
const result = SignUpSchema.safeParse({ email: "abc", password: "123" });
if (!result.success) {
console.log(z.flattenError(result.error));
}
{
formErrors: [],
fieldErrors: {
email: [ 'Email invalide' ],
password: [ 'Le mot de passe doit contenir au moins 8 caractères' ]
}
}
z.flattenError agrège les erreurs par nom de champ. Ce format convient pour retourner au frontend. Quand vous devez enregistrer pour les lecteurs, vous utilisez z.prettifyError(result.error) pour obtenir une chaîne multi-lignes facile à lire.
7. Coerce, transform et refine
Les données réelles correspondent rarement parfaitement au schéma. Zod dispose de trois outils pour gérer cette situation.
7.1. z.coerce : forcer le type d’entrée
Les chaînes de requête et les variables d’environnement sont toujours des chaînes. z.coerce les convertit avant validation :
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().max(100).default(20),
});
console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }
Remarque :
z.coerce.number()appelleNumber(input). Ainsi, la chaîne vide""devient0et passe la vérification de type. Ajoutez toujours une contrainte comme.min(1)pour empêcher cela.
7.2. transform : changer après validation
const OrderCodeSchema = z
.string()
.trim()
.regex(/^ord-\d{6}$/i, { error: "Le code de commande n'a pas le bon format" })
.transform((code) => code.toUpperCase());
console.log(OrderCodeSchema.parse(" ord-000123 "));
ORD-000123
Après transform, les types d’entrée et de sortie peuvent être différents. Vous utilisez z.input<typeof S> et z.output<typeof S> pour obtenir chaque type séparément. z.infer équivaut à z.output.
7.3. refine : règle métier arbitraire
Il y a des contraintes qui concernent plusieurs champs à la fois. Vous placez refine au niveau de l’objet pour les gérer :
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
})
.refine((b) => b.checkOut > b.checkIn, {
error: "La date de départ doit être après la date d'arrivée",
path: ["checkOut"],
});
const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] La date de départ doit être après la date d'arrivée
path attache l’erreur au champ checkOut au lieu de l’attacher à tout l’objet. De cette façon, le frontend affiche l’erreur au bon endroit.
8. Exemples pratiques
8.1. Valider les requêtes de création de commandes
L’exemple ci-dessous est une fonction de gestion de requête, indépendante du framework. Vous pouvez l’utiliser avec Express, Fastify ou Next.js :
import { z } from "zod";
const OrderItemSchema = z.object({
productId: z.uuid(),
quantity: z.number().int().min(1).max(99),
});
export const CreateOrderSchema = z.object({
customerId: z.number().int().positive(),
items: z.array(OrderItemSchema).nonempty({ error: "La commande doit contenir au moins 1 produit" }),
note: z.string().max(500).optional(),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
export function handleCreateOrder(body: unknown) {
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return { status: 400, body: z.flattenError(result.error).fieldErrors };
}
const order: CreateOrderRequest = result.data;
return { status: 201, body: { itemCount: order.items.length } };
}
Remarquez que le paramètre body a le type unknown, pas any. C’est pourquoi TypeScript vous force à valider avant d’utiliser. Après safeParse, result.data a le type complet. Par défaut, z.object supprime aussi les champs superflus, donc le client ne peut pas “glisser” un isAdmin: true dans votre objet.
8.2. Valider les variables d’environnement au démarrage
L’application doit s’arrêter immédiatement si la configuration est mauvaise, ne pas attendre jusqu’à la première requête pour le découvrir :
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env);
Supposons que DATABASE_URL manque. L’application lèvera ZodError dès l’importation du module, avec un message indiquant clairement que DATABASE_URL manque. Partout ailleurs, env.PORT a le type number et non string | undefined.
9. Écrire des tests avec Vitest
Les schémas contiennent des règles métier et doivent donc être testés. Commencez par installer Vitest :
npm install -D vitest
Écrivez ensuite des tests pour CreateOrderSchema :
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";
describe("CreateOrderSchema", () => {
const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };
it("accepte une commande valide et ignore les champs superflus", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [validItem],
isAdmin: true,
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ customerId: 7, items: [validItem] });
});
it("refuse une commande sans produits", () => {
const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });
expect(result.success).toBe(false);
expect(z.flattenError(result.error!).fieldErrors.items).toContain(
"La commande doit contenir au moins 1 produit",
);
});
it("refuse une quantité qui dépasse la limite", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [{ ...validItem, quantity: 100 }],
});
expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
});
});
Lancez npx vitest run et vous verrez tous les trois tests réussir. Le troisième test montre que le path de l’erreur va jusqu’à l’élément du tableau. C’est exactement l’information dont le frontend a besoin pour colorer le bon champ en rouge.
10. Erreurs courantes et quand ne pas l’utiliser
Forcer le type avec as après avoir un schéma. Écrire body as CreateOrderRequest brisera tout le mécanisme de protection. Récupérez toujours les données depuis result.data.
Utiliser refine asynchrone avec parse. Par exemple, vérifier si un email existe déjà dans la base de données. Quand vous avez un refine async, vous devez appeler parseAsync ou safeParseAsync. Sinon, Zod lèvera une erreur.
S’attendre à ce que les champs superflus soient rejetés. Par défaut, z.object supprime silencieusement les champs superflus. Si vous voulez signaler une erreur quand il y a un champ en trop, par exemple pour détecter les fautes de frappe dans un fichier de configuration, utilisez z.strictObject(...).
Valider à plusieurs reprises à l’intérieur de l’application. Vous ne devriez valider qu’aux limites du système : requêtes, réponses d’API tierces, files de messages, fichiers de configuration. Une fois les données validées, faites confiance à leur type. Re-parser à chaque fonction ne fait que gaspiller le CPU.
Remarque : N’utilisez pas Zod dans les boucles chaudes, où vous devez traiter des centaines de milliers d’objets par seconde. La validation a un coût réel. Dans ces cas-là, validez une fois à l’entrée ou choisissez une bibliothèque qui compile le schéma en code.
Déclarer le schéma à l’intérieur d’une fonction. Si vous créez un schéma à chaque appel de fonction, l’application paiera le coût d’initialisation à chaque fois. Déclarez le schéma au niveau du module et réutilisez-le.
11. Conclusion
Zod comble le vide entre les types au moment de la compilation et les données réelles au runtime. Vous déclarez un schéma une seule fois, déduisez le type avec z.infer, puis utilisez safeParse aux limites du système pour obtenir des données propres ou une liste d’erreurs structurées.
Avec coerce, transform et refine, vous gérez la plupart des situations réelles : chaînes de requête, normalisation de données, règles métier multi-champs. Validez tôt, validez aux limites, et laissez TypeScript gérer le reste.