JSON Toolkit

Pourquoi un JSON Schema généré automatiquement rejette sans cesse des données réelles

L’essentiel : Les schémas générés bloquent souvent des données valides. Comprenez pourquoi des échantillons plus riches produisent des schémas pires, puis le workflow : échantillonnage multi-enregistrements, complétion sémantique, localisation des erreurs.

Intermédiaire~14 min de lecturePublié le 2026-10-30Mis à jour le 2026-10-30
Illustration montrant les conflits entre un brouillon JSON vers Schema et des données réelles

L'essentiel : faites converger à la main la frontière obligatoire/facultatif d'abord — fournissez 5 à 20 enregistrements de formes différentes avec « tout obligatoire » désactivé — puis générez le schéma et ajoutez manuellement format, enum et additionalProperties ; un seul échantillon complet sera toujours trop strict.

Le générateur ne voit que l'unique enregistrement que vous avez collé. Il lit « cet enregistrement a un e-mail » comme « chaque enregistrement doit avoir un e-mail », donc plus vos échantillons sont complets, plus il exige.

1. Un générateur fait de la correspondance structurelle, pas de la compréhension métier

Confiez un bloc de JSON à un générateur et il fait exactement une chose : parcourir la structure des valeurs et traduire chaque nœud en mots-clés de schéma. Toute la table de correspondance est figée :

Valeur dans l'échantillon Type inféré Remarques
"hello" {"type": "string"} Ne distingue pas e-mail, date, UUID
42 {"type": "number"} Un entier n'est pas inféré comme integer
3.14 {"type": "number"} Idem ci-dessus
true {"type": "boolean"} —
null {"type": "null"} Ne reconnaît que null, jamais « nullable »
[1, "a"] {"type": "array", "items": {"type": ["number", "string"]}} Les types d'éléments sont réunis
{"a": 1} {"type": "object", "properties": {...}, "required": [...]} required est le nœud du problème

Toute la difficulté est dans la dernière ligne. required n'est pas dérivé des types ; il est dérivé de « cette clé existe dans cet échantillon ». Quand l'échantillon est un seul enregistrement, cette affirmation équivaut à « chaque clé est obligatoire ».

Or les données renvoyées par de vraies API ont presque toujours la moitié de leurs champs facultatifs. C'est là que le conflit commence.

2. Cinq enregistrements de même forme, cinq taux d'acceptation

Commençons par des mesures. Quiconque regarde les cinq enregistrements ci-dessous dirait « ils ont la même forme » :

[
  {
    "id": 1,
    "name": "Ada",
    "email": "ada@example.com",
    "tags": ["a"],
    "score": 10
  },
  { "id": 2, "name": "Bob", "tags": [], "score": 7 },
  {
    "id": 3,
    "name": "Cy",
    "email": "cy@example.com",
    "tags": ["a", "b"],
    "score": 8.5
  },
  { "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
  {
    "id": 5,
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["d"],
    "score": 6,
    "admin": true
  }
]

email apparaît dans 3 des 5 enregistrements, admin n'apparaît qu'une fois, et l'enregistrement 2 a un tableau tags vide.

Générez maintenant un schéma à partir de chaque enregistrement individuellement, puis utilisez chaque résultat pour valider les cinq enregistrements (validateur : ajv 8) :

Généré depuis Nombre de clés required généré Acceptés sur 5
Enregistrement 1 5 id, name, email, tags, score 3 enregistrements
Enregistrement 2 4 id, name, tags, score 5 enregistrements
Enregistrement 3 5 id, name, email, tags, score 3 enregistrements
Enregistrement 4 5 id, name, email, tags, score 1 enregistrement
Enregistrement 5 6 id, name, email, tags, score, admin 1 enregistrement

Aucun des cinq schémas n'est correct, mais le seul qui accepte toutes les données vient de l'enregistrement 2 — celui auquel manque email. Il gagne pour une raison simple : les clés qui n'apparaissent jamais ne sont jamais écrites dans required.

L'enregistrement 4 est encore plus instructif : sa valeur email est null, donc le générateur code en dur {"type": "null"}, et les quatre autres enregistrements avec des e-mails sous forme de chaîne sont tous rejetés.

3. Pourquoi « coller l'enregistrement le plus complet » est le pire choix

L'intuition est « colle l'échantillon avec le plus de champs et le schéma sera complet ». Les mesures montrent exactement l'inverse — et un tiers a mené un test contrôlé plus large.

absurdtools a utilisé 3000 « familles de données » aléatoires, 8 enregistrements par famille, chaque champ facultatif étant présent environ une fois sur deux, et a mesuré quelle part des données un schéma généré à partir d'échantillons de différents nombres de clés pouvait accepter :

Nombre de clés de l'échantillon Part des données de la famille acceptée
2 100,0 %
3 56,8 %
4 34,8 %
5 23,4 %
6 17,7 %
7 14,9 %

La conclusion n'est pas « une légère baisse » mais un renversement complet : l'échantillon minimal a produit le schéma le plus permissif dans 95 % des familles, tandis que l'échantillon le plus complet n'y parvenait que 0,1 % du temps.

La raison tient à la sémantique de required, qui signifie « tous » ; le caractère facultatif ne peut s'observer que par l'absence. Coller un enregistrement avec tous les champs dit au générateur « aucune de ces clés ne peut être omise » ; coller un enregistrement avec un champ manquant lui fournit la preuve que « cette clé peut être absente ».

Retenez-le en une phrase : la valeur d'un échantillon n'est pas sa complétude, mais les absences qu'il couvre.

Combien d'enregistrements suffisent

La même série d'expériences a aussi mesuré la vitesse de convergence : en accumulant les enregistrements un par un selon la règle « une clé reste obligatoire seulement si chaque enregistrement vu jusqu'ici la possède », le nombre médian d'enregistrements pour que required converge vers le vrai ensemble obligatoire est 3 ; 6 enregistrements couvrent environ quatre-vingt-dix pour cent des familles, et 12 les couvrent toutes.

Échantillonner 5 à 10 enregistrements au quotidien est donc le meilleur rapport effort/valeur ; en forcer vingt ou plus n'apporte que très peu de gain.

Mais il y a une condition préalable : ces enregistrements doivent venir de situations réellement différentes. Cinq enregistrements copiés du même gabarit cessent de fournir toute information nouvelle dès le deuxième — leurs ensembles de champs sont identiques, et required ne peut pas se réduire d'une seule clé.

4. La correction : fournir plusieurs enregistrements à la fois et laisser parler les absences

Puisque l'échantillonnage mono-enregistrement est nécessairement trop strict, la bonne approche consiste à laisser le générateur examiner plusieurs enregistrements à la fois et faire converger required selon la règle « obligatoire seulement si chaque enregistrement le possède ».

Le générateur de ce site accepte directement un tableau et propose une bascule « tout obligatoire ». Désactivez-la et il décide selon le nombre d'occurrences : seules les clés présentes dans chaque enregistrement sont écrites dans required.

Encore ces cinq enregistrements — cette fois, combinez-les en un tableau et inférez ensemble, puis prenez items comme contrat d'élément :

Enregistrements fournis required inféré Validation des 5
1 enregistrement id, name, email, tags, score 3 passent
2 enregistrements id, name, tags, score 4 passent
3 enregistrements id, name, tags, score 4 passent
4 enregistrements id, name, tags, score 5 passent
5 enregistrements id, name, tags, score 5 passent

required converge dès l'ajout du quatrième enregistrement, et ne change plus ensuite. email et admin sont correctement placés dans properties mais pas dans required. Et comme l'e-mail de l'enregistrement 4 est null, l'inférence multi-enregistrements le combine en {"type": ["string", "null"]}, donc chaînes et null passent — ce que l'échantillonnage mono-enregistrement ne peut jamais faire.

C'est du même ordre de grandeur que le constat d'absurdtools : « required converge après un nombre médian de 3 enregistrements ».

L'inférence multi-enregistrements a toutefois ses angles morts : si l'email de ces enregistrements se trouve être null dans chacun (par exemple parce qu'ils viennent tous de comptes sans profil rempli), le résultat d'inférence devient {"type": "null"}, et les e-mails sous forme de chaîne sont encore rejetés. De même, si chaque valeur tags est un tableau vide, le type d'élément ne peut pas du tout être inféré.

L'échantillonnage a donc une exigence supplémentaire ferme : chaque forme de chaque champ doit apparaître au moins une fois. Apportez une chaîne, un nombre, un null et un tableau vide ; ne laissez aucune forme totalement absente.

Concrètement :

  1. Récupérez 5 à 20 réponses depuis les journaux, les mocks ou de vraies requêtes, et assurez-vous d'inclure les lignes anormales (celle avec des champs vides, celle avec null, celle avec un tableau vide) ;
  2. Assemblez-les en un tableau JSON et collez-le dans le générateur ;
  3. Désactivez « tout obligatoire » et choisissez le draft 2020-12 ;
  4. Le résultat est type: array plus un items ; prenez la section items comme contrat d'un enregistrement unique ;
  5. Validez immédiatement les échantillons d'origine avec ce schéma — 100 % doivent passer. Si un seul échoue, votre échantillonnage est insuffisant ; revenez ajouter des enregistrements.

L'étape 5 est l'auto-vérification la moins coûteuse. Si le générateur ne peut même pas accommoder les données que vous lui avez fournies, votre méthode d'échantillonnage est fausse, et il est inutile d'aller plus loin.

5. Quatre choses qu'un générateur ne peut pas remplir — à écrire à la main

Même après avoir fourni plusieurs enregistrements, les éléments suivants ne peuvent pas être inférés et doivent être ajoutés manuellement :

Élément manquant Comportement mesuré Conséquence Comment corriger
Format de chaîne "a@b.com" ne produit que {"type": "string"} Valider "not an email" passe Ajouter "format": "email" et activer les assertions de format
Plages et énumérations Ne voit pas que score est limité à 0–100, ni que status n'a que trois valeurs Nombres négatifs, valeurs énormes et valeurs d'énumération invalides passent tous Ajouter minimum / maximum / enum
Politique de champs inconnus additionalProperties n'est pas généré Une clé mal orthographiée est acceptée silencieusement, ce qui est plus dangereux qu'une erreur Ajouter "additionalProperties": false pour les fichiers de type configuration
Nullabilité et sémantique des collections vides Un seul échantillon contenant null se fige en {"type": "null"} ; un tableau vide produit "items": {} Des valeurs valides sont rejetées, ou les éléments de tableau sont totalement non contraints Passer à ["string", "null"], et donner de vrais éléments aux tableaux vides

La troisième ligne mérite une mention particulière : la valeur par défaut de additionalProperties est « autoriser ». Cela signifie qu'un fichier de configuration qui écrit timeout comme timeuot passe la validation puis ne fait silencieusement rien à l'exécution. Pour les schémas de type configuration, c'est un élément qu'il faut désactiver explicitement.

format a aussi un piège : depuis le draft 2019-09, format appartient au vocabulaire d'annotation, et par défaut il annote seulement au lieu d'appliquer. Si vous avez ajouté format: "email" et que des valeurs illégales passent encore, dans huit cas sur dix le validateur n'a pas activé validateFormats ou le plug-in de format correspondant — ce n'est pas le schéma qui est faux.

Une note sur le choix du draft pendant qu'on y est : si le schéma doit entrer dans OpenAPI 3.1, il doit être en draft 2020-12, car 3.1 intègre directement cette version ; les chaînes d'outils plus anciennes (l'écosystème Swagger 2.0 et certaines implémentations $ref) restent centrées sur draft-07. Les deux se comportent différemment pour les mots-clés frères de $ref et pour les mots-clés liés aux tableaux, donc ne les mélangez pas dans un même ensemble de schémas.

6. Pour les API tierces non documentées, reconstruire le contrat à partir d'échantillons de réponse

Intégrer un callback tiers, travailler avec un partenaire qui n'écrit aucune documentation, ou maintenir un système hérité de dix ans — tout cela signifie « les données sont en main, la spécification ne l'est pas ». Ici, un générateur n'est pas seulement une commodité ; c'est le chemin le plus rapide vers un contrat.

La méthode est la même que ci-dessus, avec deux impératifs supplémentaires :

Un : échantillonner dans le temps et selon les états. Les champs tiers changent souvent avec l'état métier — une promotion ajoute un discount, une commande remboursée ajoute un refundAt, et une réponse d'erreur n'a pas du tout de data. Prendre un seul instantané signifie ne voir qu'un état.

Fournissez ensemble quelques succès, quelques échecs et quelques résultats vides : error et data apparaissent souvent de façon mutuellement exclusive, et l'inférence multi-enregistrements les place tous deux dans properties tout en les laissant hors de required — ce qui correspond exactement à la réalité.

Deux : stockez le schéma dans le dépôt et attachez une vérification quotidienne. Validez les réponses réelles du jour contre lui, et vous apprendrez que des champs disparaissent ou changent de type avant vos utilisateurs.

// Run once a day: contract-drift alert
const drifted = records.filter((record) => !validate(record));

if (drifted.length > 0) {
  console.warn(
    `${drifted.length} responses do not match the contract; first difference:`,
    validate.errors,
  );
}

La valeur de cette pratique n'est pas « économiser l'effort de l'écrire à la main », mais transformer un accord verbal en alerte exécutable. Un tiers ne vous prévient jamais à l'avance qu'il a changé un champ, mais le changement apparaîtra certainement dans vos journaux.

Une frontière à tracer clairement : le schéma reconstruit décrit ce que l'autre partie renvoie maintenant, pas ce qu'elle promet de renvoyer. Le premier sert à la surveillance ; seul le second appartient à un contrat ou à une documentation d'API.

7. Après complétion, faire pointer les erreurs de validation vers des champs précis

Une fois le schéma utilisable, la préoccupation suivante est celle d'erreurs lisibles. Un objet d'erreur ajv a cinq champs, et les quatre premiers sont les utiles :

Champ Signification Exemple (id: 0 violant minimum: 1)
instancePath JSON Pointer vers la valeur fautive dans les données "/id"
schemaPath Emplacement de la règle déclenchée dans le schéma "#/properties/id/minimum"
keyword Nom de la contrainte échouée "minimum"
params Détails lisibles par machine de cette contrainte { "comparison": ">=", "limit": 1 }
message Prose anglaise qui change entre versions, ne la parsez pas "must be >= 1"

Une asymétrie est la plus facile à heurter : quand required échoue, instancePath pointe l'objet parent (souvent la chaîne vide, c'est-à-dire la racine), pas le champ manquant. Le nom de la clé manquante est dans params.missingProperty. De même, quand additionalProperties échoue, le nom de la clé superflue est dans params.additionalProperty.

Donc, quand vous mappez les erreurs vers « champ → message », traitez d'abord ces deux cas particuliers :

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

/** Convert the ajv error array into { fieldPath: [message] } for form highlighting */
function groupByField(errors) {
  const grouped = {};

  for (const error of errors) {
    // For required and additionalProperties, the offending field lives in params, not in instancePath
    const field =
      error.keyword === "required"
        ? error.params.missingProperty
        : error.keyword === "additionalProperties"
          ? error.params.additionalProperty
          : error.instancePath.replace(/^\//u, "").split("/").join(".") ||
            "(root)";

    (grouped[field] ??= []).push(error.message);
  }

  return grouped;
}

if (!validate(data)) console.log(groupByField(validate.errors));

allErrors: true n'est pas facultatif. Avec la configuration par défaut, ajv s'arrête à la première erreur, donc l'utilisateur corrige un champ, resoumet, reçoit l'erreur suivante, et a besoin de trois allers-retours rien que pour remplir un formulaire. Si vous utilisez aussi ajv-errors pour des messages personnalisés, sans ce drapeau le plug-in ne remplacera silencieusement que certains messages.

8. Un processus de déploiement réutilisable

Enchaînez les étapes ci-dessus en une routine fixe et suivez-la chaque fois que vous intégrez une nouvelle API :

  1. Échantillonner : récupérez 5 à 20 réponses réelles, en choisissant délibérément celles avec des champs vides, des null, des tableaux vides et des champs superflus ;
  2. Inférer : collez-les ensemble en tableau dans le générateur, désactivez « tout obligatoire », et prenez items ;
  3. Compléter la sémantique : ajoutez format, enum, minimum/maximum, minItems et additionalProperties un élément à la fois ;
  4. Auto-vérifier : les échantillons d'origine doivent passer à 100 %, sinon revenez à l'étape 1 et ajoutez des échantillons ;
  5. Preuve négative : préparez une entrée « doit échouer » pour chaque contrainte et confirmez qu'elle échoue réellement — tester seulement le chemin qui passe revient à ne pas tester ;
  6. Committer : placez le schéma sous contrôle de version, conservez les échantillons qui échouent comme cas de test, et lancez les tests en premier chaque fois que l'API change un champ.

Le test négatif de l'étape 5 est le plus facile à sauter. Ajouter une clé à required sans jamais vérifier qu'une clé manquante produit une erreur revient à parier que le validateur est d'accord avec votre modèle mental.

import { describe, expect, it } from "vitest";

describe("user schema", () => {
  it("accepts a complete record", () => {
    expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
  });

  it("rejects a record without name", () => {
    expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
    // Also assert the reason, so the test doesn't pass because some other field failed
    expect(
      validate.errors?.some((e) => e.params?.missingProperty === "name"),
    ).toBe(true);
  });
});

Cette dernière assertion compte. N'asserter que false peut rester au vert même après que le schéma a été cassé — l'échec peut alors venir d'une cause complètement différente.

9. FAQ

Le schéma produit par un générateur peut-il aller directement en production

Non. Ce n'est qu'un instantané de la structure. Vous devez au minimum ajouter formats, plages, énumérations et politique de champs inconnus, puis faire passer les échantillons d'origine et les échantillons négatifs. Le générateur résout « taper properties à la main est fastidieux », pas « il décide des règles métier ».

Pour le système de mots-clés de la rédaction de schémas, le découpage par $ref et les mots-clés de composition, voir JSON Schema : des bases à la production.

Pourquoi l'enregistrement avec le plus de champs rejette-t-il plus de données

Parce que required est collecté à partir de « ce qui est apparu dans l'échantillon ». Un échantillon complet transforme chaque clé facultative en clé obligatoire, alors que la plupart de ces clés sont absentes dans les données réelles. Fournir plusieurs enregistrements, ou un enregistrement qui omet délibérément des champs, vous rapproche plus de la vérité que l'échantillon le plus complet.

Un entier sera-t-il inféré comme integer

Le générateur de ce site ne le fait pas : entiers et flottants sont tous deux produits comme number, donc un schéma généré à partir de {"price": 5} accepte 5.99. Cela évite des accidents comme « l'échantillon se trouvait être un entier, donc la production rejette les prix avec décimales ». Certains outils tiers infèrent integer à partir de la valeur, soyez donc particulièrement prudent avec les champs monétaires.

Pour les détails sur la précision des nombres et les frontières de types, voir Les six types de données JSON et les pièges de précision des nombres.

J'ai ajouté format mais cela n'a aucun effet

Depuis le draft 2019-09, format est une annotation seule par défaut. Activez explicitement les assertions de format dans le validateur (ajv a besoin de ajv-formats), ou utilisez pattern pour une contrainte dure. Ne supposez pas qu'en l'ajoutant, il bloquera quoi que ce soit.

required signale une erreur, mais le champ est clairement présent dans les données

Regardez d'abord instancePath. Une erreur required pointe l'objet parent, ce qui signifie que la clé manquante n'est pas à ce niveau — les causes courantes sont un mauvais niveau d'imbrication (/profile/name n'est pas la même chose que name à la racine), une casse différente (userId contre userID), ou une sérialisation amont qui supprime entièrement undefined.

Avant d'enquêter, confirmez que le document lui-même est du JSON valide : les commentaires, virgules finales et problèmes similaires échouent à l'étape de parsing, avant même que le schéma ait une chance. Analyser et formater JSON pointe directement la ligne fautive.

Combien d'enregistrements échantillonner, et quel draft utiliser ?

Échantillonner 5 à 10 enregistrements au quotidien donne le meilleur rapport ; required converge à un nombre médian de 3 enregistrements, et 12 enregistrements couvrent toutes les familles. Chaque enregistrement doit venir d'une situation réellement différente, et chaque forme de chaque champ doit apparaître au moins une fois. Fixez le draft à 2020-12, car OpenAPI 3.1 n'accepte que cette version.

Pourquoi un additionalProperties permissif par défaut est-il plus dangereux ?

Le défaut n'émet aucun mot-clé, donc une clé mal orthographiée est acceptée en silence, passe la validation, puis échoue à l'exécution — plus difficile à diagnostiquer qu'une erreur directe. Les schémas de type configuration doivent définir explicitement "additionalProperties": false, tandis que les schémas d'API doivent d'abord peser le coût de compatibilité.

Faut-il utiliser un schéma reconstruit comme barrière stricte ?

Non. Un schéma reconstruit décrit ce que l'autre partie renvoie maintenant, pas ce qu'elle promet de renvoyer, donc il appartient à la surveillance et aux alertes de dérive. Ce qui entre dans un contrat ou une documentation d'API doit être le contrat promis.