Accueil / Articles / Comment une erreur de type getter a silencieusement coûté à Zod 3 fois moins de performance en temps d’exécution

Comment une erreur de type getter a silencieusement coûté à Zod 3 fois moins de performance en temps d’exécution

Un aperçu des mécanismes par lesquels l’émission de getters en CommonJS dans TypeScript a empêché l’inlineage par V8 dans Zod, ainsi que des changements apportés lors de la refonte globale de Zod 4.

1719 mots

Le problème : les getters sont invisibles au JIT

Lorsque TypeScript compile une instruction de réexportation comme export * from './schemas', il ne se contente pas de copier les valeurs. Au lieu de cela, il génère un getter pour chaque nom exporté — une petite fonction qui s’exécute à chaque fois que la propriété est lue, plutôt que d’exposer une propriété statique ordinaire contenant directement la valeur.

Normalement, il s’agit d’un détail d’implémentation que personne ne remarque. Dans le cas de Zod, cela a eu une grande importance : 252 des 255 exportations du point d’entrée CommonJS de Zod 4.5 ont été implémentées sous forme de getters. Le JIT de V8 est excellent pour l’inlineage — en remplaçant une appel de fonction par son corps réel afin que le moteur élimine les surcoûts liés aux appels — mais uniquement lorsqu’il peut garantir que la fonction cible est stable et prévisible. Un getter brise cette garantie. V8 ne peut pas voir une fonction fixe et immuable cachée derrière un getter, il ne peut donc pas inlineer en toute sécurité ce qui est accessible de cette manière.

La correction apportée dans Zod 4.6 semble presque trop simple : émettre des propriétés ordinaires au lieu de getters, et geler l’objet d’exportation résultant afin que V8 sache qu’il ne changera jamais. Voici à quoi ressemble cette approche en pratique :

// CommonJS require — this is the path that was affected
const { z } = require("zod");
const CompiledPlayer = z.compile(Player);
z.validate(CompiledPlayer, data);
// ~3x faster in Zod 4.6 than the identical call in Zod 4.

Il est important d’être précis quant à l’étroitesse réelle de cette correction, car il est facile d’exagérer son champ d’application. Seules les appels acheminés par l’intermédiaire de l’objet namespace — comme z.validate(...) ou z.compile(...) — ont été affectés, et ce uniquement lors de l’utilisation de require(). L’appel direct d’une méthode sur une instance de schéma, par exemple Player.safeParse(data), ne touche jamais l’objet exports, de sorte que ce schéma n’a jamais été impacté. La version ESM n’a pas du tout été affectée ; il s’agissait strictement d’une particularité des re-exports de CommonJS.

La leçon à retenir va bien au-delà de Zod lui-même : la forme du code généré par votre compilateur a des conséquences réelles en temps de exécution qui n’ont rien à voir avec la logique que vous avez réellement écrite. Personne n’écrivait un code inférieur en utilisant Zod 4.5 par rapport à ceux qui utilisaient la version 4.6 — l’appel identique à z.validate() devenait simplement plus rapide parce qu’un outil distinct, le compilateur TypeScript, structurait différemment son output généré.

La réécriture plus importante derrière tout cela

Cette correction n’est qu’une petite partie d’un effort bien plus vaste : Zod 4, stable depuis 2025, est une réécriture complète, et les gains de vitesse qu’il offre sont considérables en soi. Des tests indépendants ont montré que l’analyse de chaînes de caractères se fait environ quatorze fois plus rapidement, celle des tableaux environ sept fois plus vite, et l’analyse des objets près de six fois et demie plus rapidement, tous ces résultats étant mesurés par rapport à Zod 3. Cependant, le changement qui aura le plus d’impact sur votre workflow quotidien n’a rien à voir avec la vitesse de exécution : les instances du compilateur TypeScript pour un schéma typique sont passées de plus de 25 000 à environ 175 — ce qui explique pourquoi les éditeurs et les outils de vérification de types ralentissaient auparavant sur de gros projets utilisant beaucoup Zod, et pourquoi cela ne se produit plus fréquemment aujourd’hui.

Pour les situations où la taille du bundle est cruciale — fonctions d’edge, widgets côté client — Zod Mini offre le même ensemble de validateurs via une interface fonctionnelle entièrement exploitable pour le tree-shaking, plutôt que le style de méthodes enchaînées familier à Zod :

// Standard Zod — method chaining
import * as z from "zod";
const User = z.object({ name: z.string(), age: z.number() });
// Zod Mini - same validators, functional style, smaller bundle
import * as z from "zod/mini";
const User = z.object({ name: z.string(), age: z.number() });

Qu’est-ce qui a vraiment changé dans l’API

C’est ici qu’un simple npm install zod@^4 peut briser silencieusement du code existant, il est donc utile d’examiner chaque changement directement plutôt que de se fier à un résumé du changelog.

Les validateurs de format de chaîne sont devenus des fonctions de niveau supérieur exploitables pour le tree-shaking :

// Zod 3 style — deprecated, but still works
const schema = z.string().email();
// Zod 4 - the new standard
const schema = z.email();
const id = z.uuid();
const site = z.url();

Quatre mécanismes distincts pour personnaliser les messages d’erreur ont été fusionnés en une seule option :

// ❌ Zod 3 — three different mechanisms
const schema = z.string({
  required_error: "Name is required",
  invalid_type_error: "Name must be a string",
});
const age = z.number({
  errorMap: (issue, ctx) => {
    if (issue.code === "too_small") return { message: "Must be 18+" };
    return { message: ctx.defaultError };
  },
});
// ✅ Zod 4 - one parameter, string or function
const schema = z.string({ error: "Name is required" });
const age = z.number({
  error: (issue) => {
    if (issue.code === "too_small") return "Must be 18+";
    return "Invalid age";
  },
});

Le formatage des erreurs a été séparé de l’objet d’erreur pour devenir des fonctions d’aide indépendantes :

const result = User.safeParse(input);
if (!result.success) {
  result.error.issues;              // the raw array - was .errors in Zod 3
  z.treeifyError(result.error);     // nested shape, replaces .format()
  z.flattenError(result.error);     // { formErrors, fieldErrors }, replaces .flatten()
  z.prettifyError(result.error);    // human-readable string, great for logs
}

app.post("/users", (req, res) => {
  const result = User.safeParse(req.body);
  if (!result.success) {
    const { fieldErrors } = z.flattenError(result.error);
    return res.status(400).json({ errors: fieldErrors });
  }
  // result.data is fully typed here
  createUser(result.data);
});

ZodError.errors a disparu, remplacé par .issues. Si une partie de votre logique de gestion des erreurs fait encore référence à error.errors, rien n’est déclenché. Elle se contente de renvoyer silencieusement undefined. Ce type d’échec passe inaperçu dans toute suite de tests qui ne vérifie pas explicitement cette propriété, et n’apparaît que lorsque un utilisateur réel le rencontre en production.

La priorité des messages d’erreur contextuels a été inversée. Sous Zod 3, une valeur de remplacement d’erreur fournie au moment du parsing avait la priorité sur celle définie dans le schéma lui-même. Sous Zod 4, cette priorité est inversée : c’est maintenant le message au niveau du schéma qui l’emporte.

const mySchema = z.string({ error: () => "Schema-level error" });
// Zod 3: this override wins → "Contextual error"
// Zod 4: the schema-level error wins instead → "Schema-level error"
mySchema.parse(12, { error: () => "Contextual error" });

Rien ne change au niveau du point d’appel, pourtant le même code renvoie un message différent en fonction uniquement de la version majeure installée — une inversion de comportement dissimulée derrière ce qui semble être une simple mise à jour de nommage.

La véritable situation concurrentielle

Il est tentant de considérer la correction 4.6 ainsi que la réécriture globale comme des preuves que Zod surpasse désormais sans conteste toutes les autres bibliothèques de validation, mais les chiffres réels exigent une conclusion plus mesurée. En effectuant un million de validations sur un objet imbriqué à huit champs sur une machine M3 Pro, ArkType met environ 820 ms, Valibot environ 1 140 ms, et Zod 4 autour de 1 380 ms. Pour donner un contexte, Zod 3 nécessitait environ 4 200 ms pour effectuer la même tâche, ce qui montre que cette réécriture représente une amélioration réelle et significative par rapport à son prédécesseur, même si elle ne devance pas les autres solutions. En ce qui concerne la taille du bundle, Valibot conserve un avantage important : un schéma typique de formulaire de connexion pèse environ 1,37 KB avec Valibot, contre environ 17,7 KB avec Zod standard, et près de 7 KB même en utilisant Zod Mini.

La conclusion plus utile qui ressort de ces mêmes chiffres est que, avec un débit de un million de validations par seconde — bien au-delà de ce dont a besoin tout point d’entrée API réaliste pour fonctionner — l’écart de performance entre ces trois bibliothèques se traduit par quelques centaines de millisecondes sur un million d’appels. Une telle différence ne sera jamais perceptible dans un trafic de production normal. Pour les services Node.js et les bases de code fortement basées sur tRPC, le soutien plus poussé offert par l’écosystème de Zod ainsi que son style familier basé sur des méthodes en chaîne auront généralement plus d’importance dans l’utilisation quotidienne que le fait de savoir quelle bibliothèque remporte un test de performance synthétique. Dans les cas où la taille du bundle est réellement un facteur limitant — comme pour les fonctions d’edge ou les validateurs envoyés au client — l’avantage de taille de Valibot est le critère décisif, indépendamment de la vitesse à laquelle chacune de ces bibliothèques effectue les validations.

Guides pratiques pour la migration

Auparavant, assurez-vous d’être sur TypeScript 5.5 ou une version ultérieure, car Zod 4 l’exige. Les méthodes obsolètes héritées de Zod 3 continuent de fonctionner, mais génèrent uniquement des avertissements en temps de exécution ; c’est précisément pour cette raison que la plupart des équipes effectuent une migration progressive, fichier par fichier, plutôt que de tenter un changement complet et risqué d’un coup. La première étape la plus importante consiste à rechercher dans l’ensemble du codebase les utilisations de .errors, .format() et .flatten() sur des objets d’erreur Zod, car ce sont justement ces modifications qui échouent silencieusement plutôt que de manière visible. De plus, si votre projet utilise déjà Zod 4 mais fonctionne via le mécanisme require() de Node.js — ce qui reste courant dans les environnements backend, même au sein de codebases utilisant ESM — mettre à jour vers 4.6 représente presque une amélioration de performance gratuite, puisque cette mise à jour ne nécessite aucun changement dans votre propre code.

La leçon principale

L’histoire derrière la correction 4.6 est simple, mais la leçon qu’elle enseigne est plus importante : ce que vous exécutez réellement en production n’est déterminé qu’à moitié par le code que vous écrivez. L’autre moitié dépend de ce que votre compilateur et votre outil de bundling choisissent d’envoyer à sa place, et cette couche générée présente ses propres comportements en termes de performance qui n’ont rien à voir avec la précision avec laquelle votre propre logique a été écrite. La plupart du temps, vous pouvez ignorer complètement cette couche sans risque. Mais de temps en temps — comme c’était le cas avec les 252 méthodes getter qui bloquaient silencieusement l’inlining de V8 pendant plus d’un an — il est utile de se rappeler que « mon code est correct » et « mon code se compile en quelque chose de rapide » sont deux affirmations distinctes. La seconde mérite d’être vérifiée de temps en temps, même lorsque rien de ce que vous avez fait n’était réellement incorrect.

Lectures complémentaires

  • Les fonctionnalités de Node.js 26 qui remplacent discrètement des années de solutions de contournement — Une présentation de l’API Temporal de Node.js 26, de l’exécution native de TypeScript, des outils d’optimisation du cache et d’autres ajouts qui éliminent les solutions de contournement utilisées depuis longtemps.