12k
All articles

Accélérer la validation Zod grâce aux schémas compilés

Découvrez comment les schémas compilés de Zod accélèrent la validation, quand le gain compte et comment gérer les coûts, les schémas non pris en charge et les limites CSP.

OpenReplay Team
OpenReplay Team
Accélérer la validation Zod grâce aux schémas compilés

Depuis Zod 4.5.0, z.compile() transforme un schéma en un validateur JavaScript spécialisé. Il vérifie les entrées valides plus rapidement et renvoie exactement les mêmes résultats et les mêmes erreurs que le schéma non compilé.

Si votre handler parse le même corps de requête volumineux des milliers de fois par minute, une couche de validation qui parcourt l’arbre du schéma à chaque appel finit par apparaître dans vos profils CPU. La version 4.5 de Zod apporte une solution à ce problème. Cet article fait suite au guide d’OpenReplay sur la validation de données en TypeScript avec Zod. Il explique comment activer la compilation, ce qu’elle coûte, dans quels cas le gain de performance est suffisant pour compter, et ce qui se passe sous une Content Security Policy stricte ou sur des runtimes comme Cloudflare Workers.

Points clés

  • z.compile(schema) génère un validateur sans boucles ni parcours avec branchements, puis l’exécute via new Function(). Une entrée valide est vérifiée par une simple suite de tests typeof au lieu d’un parcours de l’arbre du schéma.
  • Les entrées invalides n’y gagnent presque rien, car un parsing en échec exécute d’abord le chemin rapide, puis le parseur standard complet pour construire les erreurs.
  • Dans le benchmark en boucle serrée de Zod, les objets compilés de 5, 10, 20 et 50 clés sont respectivement 1,8x, 2,2x, 5,0x et 10,2x plus rapides : les petits schémas y gagnent donc peu.
  • Le compilateur ajoute environ 7 Ko (gzip) à tout bundle qui appelle z.compile() ou importe zod/compile.
  • { strict: true } fait lever ZodCompileAsyncError ou ZodCompileUnsupportedError aux schémas non compilables, au lieu d’un repli silencieux.

Comment fonctionnent les schémas Zod compilés ?

Un schéma Zod compilé valide les entrées au moyen de code généré au lieu de parcourir l’arbre du schéma. Zod lit l’ensemble du schéma une seule fois, écrit un court fragment de JavaScript sans aucune boucle, puis le transforme en fonction avec new Function(). Dès lors, une entrée valide est vérifiée en lisant chaque propriété et en testant son typeof, ligne après ligne. Si le chemin rapide rejette une entrée, Zod exécute le parseur standard sur celle-ci. C’est pourquoi un schéma compilé signale les mêmes problèmes et les mêmes messages d’erreur que l’original. Vous continuez d’utiliser .parse(), .safeParse() et les mêmes types inférés.

Activer la compilation

Deux options s’offrent à vous : compiler des schémas précis avec z.compile(), ou compiler tous les schémas de l’application en important zod/compile globalement. La compilation schéma par schéma offre un contrôle précis sur les chemins critiques. Le mode global ne nécessite qu’une seule ligne.

Schéma par schéma avec z.compile()

z.compile() vous renvoie un nouveau schéma, compilé. Le schéma passé en argument reste inchangé. Toute méthode qui construit un nouveau schéma à partir d’un schéma compilé, comme .refine(), .extend() ou .optional(), renvoie un schéma non compilé. Construisez d’abord le schéma final, puis compilez-le en dernier :

import * as z from "zod";

const Base = z.object({
  id: z.string(),
  type: z.string(),
  createdAt: z.number(),
});

const notInFuture = (e: { createdAt: number }) => e.createdAt <= Date.now();

// ❌ .refine() returns a new schema, and it is not compiled
const Wrong = z.compile(Base).refine(notInFuture);

// ✅ finish the schema, then compile it
const WebhookEvent = z.compile(Base.refine(notInFuture));

const result = WebhookEvent.safeParse(payload);

Rien ne vous signale à l’exécution que Wrong n’est pas compilé. Il valide correctement, simplement plus lentement. L’option strict (présentée plus loin) ne le détectera pas non plus, car elle ne lève une erreur que lorsqu’un schéma ne peut pas du tout être compilé. La bonne habitude consiste à faire de z.compile() le dernier appel de la chaîne.

Globalement avec zod/compile

Avec la compilation par défaut, Zod compile chaque schéma créé après l’import. Il le fait de manière paresseuse (lazy), lors du premier parsing de ce schéma :

import "zod/compile"; // must run before any module that defines schemas
import * as z from "zod";

const User = z.object({ name: z.string() });
User.parse({ name: "ok" }); // compiled on first parse

L’ordre de chargement est facile à rater en ESM ; laissez donc le runtime charger le module en premier :

node --import zod/compile app.js    # ESM
node --require zod/compile app.cjs  # CommonJS

Les utilisateurs de Bun peuvent plutôt déclarer zod/compile dans la section preload de bunfig.toml. Le mode global est destiné aux applications. Les bibliothèques ne doivent pas l’activer à la place de leurs utilisateurs.

Combien coûte la compilation Zod ?

La compilation des schémas Zod a trois coûts, et elle apporte peu pour les entrées invalides. Un parsing rejeté exécute le chemin rapide, échoue, puis exécute le parseur standard complet, qui représente la quasi-totalité du temps. Les trois coûts sont les suivants :

  1. Un travail de compilation ponctuel pour chaque schéma, effectué lors de la compilation du schéma ou, en mode global, lors de son premier parsing.
  2. La taille du bundle. Le compilateur ajoute environ 7 Ko gzip (28 Ko minifiés). D’après le tableau de Zod, un schéma d’objet à quatre clés pèse 31,1 Ko gzip avec le compilateur, contre 24,1 Ko sans. Pour Zod Mini, le saut est plus important : de 4,6 Ko à 13,2 Ko. Les bundles qui n’appellent jamais z.compile() et n’importent pas zod/compile éliminent entièrement le compilateur lors du tree-shaking.
  3. Une double exécution en cas d’échec. Un raffinement (refinement) ou une transformation s’exécute une seule fois lorsque l’entrée est valide, mais peut s’exécuter deux fois lorsqu’elle ne l’est pas. Un raffinement qui journalise, incrémente un compteur ou écrit des données le fera donc deux fois pour un webhook rejeté.

À la frontière des requêtes, avec un trafic soutenu, le travail ponctuel est vite amorti. Dans un script ponctuel ou une CLI qui parse un seul fichier de configuration, le travail de compilation et les octets supplémentaires ne seront peut-être jamais rentabilisés. Un trafic dominé par des entrées invalides, comme des sondes de bots ou des signatures de webhook falsifiées, en tire lui aussi peu de bénéfice.

Où les gains de performance de Zod se manifestent-ils ?

Les gains de performance apportés par la compilation augmentent avec la taille du schéma. Les objets larges et les tuples en profitent le plus, car le code généré vérifie chaque clé à la suite, sans la boucle par clé du parseur standard. Le benchmark de Zod mesure chaque schéma isolément, de manière répétée dans une boucle serrée. Le parseur standard donne le meilleur de lui-même dans ce contexte : les gains présentés ici sont donc inférieurs à ceux du graphique principal affiché en haut de la même page.

SchémaAccélération
Objet, 5 clés1,8x
Objet, 10 clés2,2x
Objet, 20 clés5,0x
Objet, 50 clés10,2x
Tuple, 1 élément2,2x
Tuple, 3 éléments2,5x
Tuple, 5 éléments3,0x
Tuple, 10 éléments3,7x

Un formulaire de connexion à trois champs ne verra pas la différence. Un payload d’événement de 50 clés parsé à chaque requête, si. Les chiffres plus élevés, comme le « jusqu’à 44x » (et jusqu’à 46x sur les entrées rejetées) annoncé pour zod-compiler, proviennent d’outils tiers distincts qui génèrent les validateurs au moment du build. Ils ne concernent pas le compilateur d’exécution intégré à Zod.

Quels schémas Zod ne peuvent pas être compilés ?

Certaines fonctionnalités des schémas Zod ne peuvent pas être compilées. Lorsque z.compile() en rencontre une, il ne lève pas d’erreur : il vous renvoie discrètement le schéma passé en argument, sans compilation. La liste des fonctionnalités non prises en charge détermine si vous perdez la compilation du schéma entier ou d’un seul enfant :

FonctionnalitéEffet
Raffinements, transformations ou vérifications asynchrones n’importe où dans l’arbreRepli du schéma entier
.catch() avec un callback (.catch(value) se compile)Repli du schéma entier
Union contenant un membre non pris en chargeRepli du schéma entier
z.xor(), schémas récursifs, z.coerce.*, vérifications avec un when personnaliséNon compilé
Enfant non pris en charge dans un objet, un tableau, un tuple, un record ou une intersectionSeul cet enfant utilise le parseur standard

La compilation ne s’applique jamais à z.encode() ni au parsing asynchrone. Tous deux passent systématiquement par le parseur standard. Si vos handlers appellent safeParseAsync, compiler le schéma ne leur apporte rien.

Pour éviter qu’une modification ultérieure ne désactive silencieusement la compilation sur un chemin critique, compilez en CI avec { strict: true } :

import { test } from "node:test";
import assert from "node:assert/strict";
import * as z from "zod";
import { WebhookEvent, OrderBody } from "../src/schemas.js";

test("hot-path schemas compile", () => {
  for (const schema of [WebhookEvent, OrderBody]) {
    assert.doesNotThrow(() => z.compile(schema, { strict: true }));
  }
});

test("async refinements are rejected under strict", () => {
  const Handle = z.string().refine(async (v) => v.length > 2);
  assert.throws(() => z.compile(Handle, { strict: true })); // ZodCompileAsyncError
});

Avec strict activé, un schéma asynchrone lève ZodCompileAsyncError, et tout autre schéma que Zod ne peut pas compiler lève ZodCompileUnsupportedError. Sans strict, aucune de ces erreurs n’est levée.

z.compile() fonctionne-t-il sous CSP et sur Cloudflare Workers ?

z.compile() échoue sans danger, mais ne vous apporte rien là où la génération dynamique de code est interdite. new Function() est bloqué sur toute page dont la Content Security Policy n’inclut pas 'unsafe-eval' dans script-src (ou dans default-src en l’absence de script-src). Zod cite également Cloudflare Workers comme un environnement où new Function() est bloqué. Si vous définissez jitless, le mode global se désactive de lui-même :

// config.ts: import this module before any module that defines schemas
import * as z from "zod";

z.config({ jitless: true });

Appeler z.compile() explicitement est un cas différent. Zod l’interprète comme une demande claire et tente donc de générer du code même lorsque jitless est activé. Si l’environnement bloque new Function, vous récupérez simplement le schéma non compilé. La validation continue de fonctionner. En revanche, vous avez livré environ 7 Ko gzip de compilateur qui ne pourra jamais s’exécuter : excluez donc zod/compile et z.compile() des builds destinés à ces environnements.

Conclusion

La compilation accélère la vérification des entrées valides sans modifier les résultats ni les erreurs, et le gain augmente avec la largeur du schéma. Commencez par profiler la frontière de vos requêtes. Compilez vos schémas les plus larges et les plus sollicités en dernier dans leur chaîne de construction, et ajoutez un test strict pour vous assurer qu’ils restent compilables. Tenez le compilateur à l’écart des builds destinés aux runtimes et aux politiques CSP qui bloquent new Function. Installez la version actuelle de Zod 4 plutôt que de figer la 4.5.0, afin de bénéficier des correctifs apportés au compilateur.

FAQ

Existe-t-il un moyen plus rapide que safeParse pour rejeter une entrée invalide avec Zod ?

Oui. Zod 4.6 a introduit .validate(), qui indique uniquement si l'entrée est valide. Cette méthode ne construit pas de ZodError, si bien que rejeter une entrée invalide ne coûte presque rien. Sur un schéma compilé avec une entrée invalide, elle peut être jusqu'à 35x plus rapide que .safeParse().success. Elle affine également le type : lorsqu'elle renvoie true, TypeScript considère la valeur comme étant du type d'entrée du schéma. Utilisez-la lorsque vous n'avez besoin que d'une réponse par oui ou par non, et conservez .safeParse() lorsque vous avez besoin du détail des erreurs.

Puis-je utiliser un parseur Zod précompilé dans les environnements qui bloquent new Function ?

Oui, à partir de Zod 4.6. z.compile() remplit deux fonctions : il génère un parseur et l'attache au schéma. z.withParser() ne remplit que la seconde. Vous lui fournissez un parseur produit ailleurs, par exemple par une étape de build ou un compilateur natif, et il l'attache selon les mêmes règles que z.compile(). Comme le code généré est livré sous forme de JavaScript ordinaire, le runtime n'a jamais besoin de new Function.

Quelle est la différence entre z.compile() et zod-compiler ?

z.compile() est intégré à Zod et génère les validateurs à l'exécution avec new Function(), au sein de votre processus. zod-compiler (gajus/zod-compiler) est un outil tiers distinct qui génère les validateurs au moment du build, via des plugins de bundler pour Vite, webpack, esbuild, Rollup et d'autres, ou via une CLI. Son code généré au build est du code standard sans eval : les règles CSP ne peuvent donc pas le désactiver. Son mode d'exécution optionnel utilise toutefois new Function. La version actuelle de zod-compiler nécessite Zod 4.5 ou ultérieur, et sa branche 1.x couvre Zod 4.0 à 4.4.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.