JSON Toolkit

Réutiliser JSON Schema : $ref, $defs et composition allOf/oneOf

L’essentiel : $ref résout la réutilisation, allOf et oneOf résolvent la composition, et c'est en empilant les deux que ça casse. Couvre $id, $defs, les conditions, la récursion, le regroupement et le débogage.

Intermédiaire~11 min de lecturePublié le 2026-10-31Mis à jour le 2026-10-31
Schéma illustrant la réutilisation et la composition JSON Schema avec $ref, $defs et allOf/oneOf

L'essentiel : extrayez d'abord les structures communes dans $defs avec $ref, puis recourez à allOf/oneOf ; quand des branches peuvent correspondre simultanément, utilisez anyOf ou un marqueur const, et réservez oneOf aux cas qui exigent vraiment exactement une correspondance.

Le copier-coller fait gagner dix minutes et vous coûte une divergence silencieuse trois mois plus tard, quand plus personne ne se souvient quelle copie est la bonne.

1. Trois couches de réutilisation — choisissez-en une avant de découper les fichiers

Le premier réflexe, quand les champs se multiplient, est d'entasser toutes les contraintes dans un seul fichier. C'est la façon la plus rapide de livrer, mais au-delà de quelques centaines de lignes, chaque changement de champ doit être répercuté à trois endroits. Oubliez-en un et vous obtenez « passe en local, renvoie 500 en production ». Réutiliser signifie écrire un fait une fois et le référencer partout ailleurs.

JSON Schema offre trois couches, chacune avec une portée différente :

Couche Support Portée Usage typique
Sous-structure $defs + $ref Un fichier de schéma Adresse, monnaie, pagination, formes d'erreur
Fichier Schéma autonome + $id Plusieurs fichiers dans un service Entités utilisateur, commande, produit
Bibliothèque de contrats Dépôt de schémas + artefact regroupé Plusieurs services, plusieurs langages Composants OpenAPI, contrats publiés

Pour la plupart des projets, la première couche suffit. Le découpage en fichiers ne vaut le coup que lorsque deux services partagent réellement le même contrat, ou lorsque le schéma est un artefact publié consommé par un autre langage. Découper trop tôt rend la résolution des $id et des chemins relatifs plus difficile à déboguer, et ce coût dépasse généralement ce que vous avez économisé en évitant la duplication.

Une règle simple : si une structure apparaît à deux endroits, une copie est tolérable ; quand elle apparaît à un troisième, promouvez-la dans $defs ; ce n'est que lorsqu'un autre langage ou dépôt doit la lire qu'elle mérite un fichier autonome avec son propre $id.

2. $id établit l'identité, $ref effectue le saut

$id n'est pas un commentaire. Il déclare l'URI de base du schéma. Une valeur $ref est une référence d'URI : elle est résolue par rapport à l'URI de base courante en une URI absolue, puis le fragment après # localise un nœud. Si l'URI de base est fausse, chaque référence relative pointe ailleurs, mais l'erreur indique souvent seulement « impossible de résoudre la référence » sans nommer le mauvais hôte.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "address": {
      "$anchor": "address",
      "type": "object",
      "required": ["city"],
      "properties": {
        "city": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" }
      }
    }
  },
  "type": "object",
  "required": ["shipTo"],
  "properties": {
    "shipTo": { "$ref": "#/$defs/address" },
    "billTo": { "$ref": "#address" }
  }
}

shipTo utilise un JSON Pointer tandis que billTo utilise une ancre ; les deux aboutissent au même nœud. Le tableau ci-dessous associe chaque forme à sa résolution :

Forme Pointe vers Draft Remarques
# Racine du schéma courant Tous Courant pour la récursion
#/$defs/address Nœud sous $defs 2019-09 et ultérieur Le plus clair ; à préférer
#address Ancre nommée $anchor en 2020-12 draft-07 écrivait $id: "#address"
common.json Fichier relatif à l'URI de base Tous Doit être enregistré ou regroupé
https://example.com/x.json URI absolue Tous À éviter en récupération à l'exécution en production

Un piège subtil : si un sous-schéma dans $defs déclare son propre $id, celui-ci devient la nouvelle URI de base pour tout ce qui est en dessous. Collez un schéma copié d'un autre fichier et le $id résiduel redirigera ses références relatives vers l'hôte d'origine — et le validateur ne le remarque qu'au moment où il essaie réellement de les résoudre.

3. $defs est un conteneur, pas un site de validation

$defs est une simple table. Rien à l'intérieur ne prend effet tant qu'un $ref ne pointe pas dessus. Une liste required placée en tête de $defs sans référence entrante ne valide strictement rien, aussi stricte soit-elle en apparence. C'est la spécification qui fonctionne comme prévu, pas un bug du validateur.

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "A two-decimal money string that avoids floating-point drift"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

Quelques conventions méritent d'être fixées en équipe :

  • Les clés dans $defs n'ont aucune sémantique. address, Address et addr sont toutes légales, alors convenez d'un style.
  • Avant 2019-09, le mot-clé était definitions. Les deux peuvent être ciblés par $ref, mais le mode strict peut signaler definitions comme mot-clé inconnu.
  • $defs peut contenir n'importe quel schéma, y compris un fragment allOf ou un sous-schéma indépendant avec son propre $id.
  • Ne traitez pas $defs comme de l'héritage de gabarit. Il ne stocke que des définitions ; le sens vient encore du contexte référençant.

4. allOf, anyOf et oneOf ne sont pas interchangeables

Les trois impliquent plusieurs branches, mais leurs règles d'acceptation diffèrent. Quand les données correspondent à deux branches, allOf et anyOf passent tandis que oneOf échoue — la raison la plus fréquente pour laquelle les gens concluent que « le schéma est cassé ».

Mot-clé Correspondances minimales Correspondances maximales Données correspondant à deux branches Qualité des erreurs Usage typique
allOf Toutes Illimitées Passe Bonne, une contrainte à la fois Combiner des fragments de contraintes
anyOf 1 Illimitées Passe Mauvaise, les branches s'empilent « E-mail ou téléphone, l'un ou l'autre »
oneOf 1 1 Échoue Mauvaise Unions discriminées
not 0 0 Échoue La pire Exclure quelques valeurs ; rarement utilisé
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf ne fusionne pas les définitions d'objets ; il exige seulement que chaque branche soit vraie. Chaque branche est évaluée indépendamment, donc un additionalProperties: false dans une branche ne connaît que les clés déclarées par cette branche et signalera comme superflus les champs déclarés par une autre branche. En 2020-12, la correction consiste à placer la politique de propriétés inconnues au niveau supérieur et à utiliser unevaluatedProperties: false, évalué une fois après toutes les branches.

Utilisez anyOf pour « plusieurs formes légales », pas pour « plusieurs formes mutuellement exclusives ». Si deux branches peuvent correspondre simultanément, oneOf produit le fameux must match exactly one schema in oneOf, peu utile.

5. if/then/else et discriminateur : rendre les conditions décidables

Les contraintes conditionnelles tendent à produire de meilleures erreurs que oneOf, parce que le chemin de décision est explicite : if passe, then s'applique ; sinon else s'applique.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": {
    "properties": { "kind": { "const": "image" } },
    "required": ["kind"]
  },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

Trois détails déterminent si le comportement est celui attendu :

  1. Une entrée properties dans if n'échoue pas quand la clé est absente. Associez-la à required pour exprimer « kind existe et vaut image ».
  2. if lui-même ne produit jamais d'erreur de validation. Son échec route simplement vers else, donc il ne peut pas bloquer des données invalides à lui seul.
  3. Un then ou un else isolé est inerte. Ils ne s'appliquent qu'à côté d'un if.

À mesure que les branches d'union se multiplient, la chaîne conditionnelle devient une imbrication ingérable. Un schéma plus robuste consiste à verrouiller un champ marqueur dans chaque branche, de sorte que les branches soient mutuellement exclusives par construction :

{
  "oneOf": [
    {
      "properties": { "kind": { "const": "card" } },
      "required": ["kind", "cardNumber"]
    },
    {
      "properties": { "kind": { "const": "account" } },
      "required": ["kind", "accountId"]
    }
  ]
}

discriminator est une extension OpenAPI 3.x, pas un mot-clé JSON Schema. Ajv le prend en charge quand l'option discriminator: true est activée, mais son mapping vit dans mapping et n'est pas portable entre chaînes d'outils. Si le schéma doit fonctionner dans plusieurs validateurs, préférez le motif de marqueur const, qui ne dépend d'aucune extension.

6. Références récursives : arbres, listes chaînées et fichiers qui se citent

Les structures récursives n'ont besoin d'aucune syntaxe spéciale — un $ref vers lui-même suffit. Les validateurs n'étendent pas les références à l'infini à la compilation ; ils récursent selon la profondeur réelle des données, donc quelques dizaines de lignes décrivent un arbre de n'importe quelle hauteur.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/tree-node.json",
  "$defs": {
    "node": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/node" }
        }
      }
    }
  },
  "$ref": "#/$defs/node"
}

Trois frontières méritent d'être fixées à l'avance :

  • Profondeur des données : le schéma n'impose aucune limite, mais la validation et la sérialisation consomment toutes deux une pile proportionnelle à la profondeur. Les API publiques devraient ajouter une limite métier, disons huit niveaux, sinon une charge utile anormalement profonde peut faire déborder la pile ;
  • Références mutuelles : A référence B et B référence A est légal, mais les deux schémas doivent être enregistrés d'abord ; sinon l'ordre de résolution décide du résultat ;
  • Branches nullables : si un champ récursif peut être null, écrivez "type": ["object", "null"] ou enveloppez-le dans oneOf. $ref n'hérite pas de la nullabilité.

7. Références entre fichiers et regroupement : ne récupérez jamais des schémas en production

Les références entre fichiers « fonctionnent souvent » en développement parce que le validateur récupère la cible par $id sur le réseau. Changez la politique réseau au déploiement et toute la chaîne de validation casse. La règle de production est simple : chaque schéma référencé doit être enregistré dans le processus ou regroupé à l'avance.

import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// Compile the entry schema once, at module scope
export const validateOrder = ajv.compile(orderSchema);

Si order.json a $id: "https://bbdrug.com/schemas/order.json", son "$ref": "address.json" se résout en https://bbdrug.com/schemas/address.json, ce qui correspond à la clé enregistrée. Un seul caractère différent produit can't resolve reference.

Les trois approches pratiques se comparent ainsi :

Approche Méthode Atout Coût
Enregistrement à l'exécution addSchema par fichier Aucune étape de build, les fichiers restent indépendants L'ordre et les clés d'enregistrement sont manuels
Regroupement au build Fusionner avec un parseur de références en un fichier Zéro dépendance à l'exécution Artefact plus gros, diffs bruyants
Chargement asynchrone loadSchema + compileAsync Gère les schémas qui changent souvent Problèmes de réseau et de cache ; à éviter sur les chemins critiques

Pour les projets frontend, le regroupement au build est plus sûr. Côté serveur, l'enregistrement suffit, à condition qu'un test prouve que chaque référence se résout. Dans les deux cas, exécutez compile en CI pour que les erreurs de référence apparaissent avant la fusion plutôt qu'à la première requête de production.

8. Ordre de débogage : résoudre, compiler, puis inspecter les branches

Presque tout problème de $ref peut être localisé en trois étapes, et l'ordre compte :

Symptôme Cause probable Correction
can't resolve reference ... from id ... Faute de frappe dans $id, mauvaise base relative, cible non enregistrée Afficher le $id et l'URI absolue résolue, puis comparer avec les clés enregistrées
schema with key or id already exists Le même $id enregistré deux fois, ou compile appelé plusieurs fois Déplacer l'enregistrement à l'initialisation du module et l'exécuter une fois
strict mode: unknown keyword: "definitions" Mélange de syntaxe draft-07 Renommer en $defs ; ne pas désactiver le mode strict
Les données correspondent à deux branches oneOf Contraintes de branches qui se chevauchent, pas de champ marqueur Ajouter const à chaque branche, ou passer à anyOf
Les contraintes dans $defs n'ont aucun effet Rien ne les référence Vérifier le chemin de référence ; $defs ne valide rien par lui-même

Lors du débogage, lisez schemaPath avant message : il nomme la branche et le mot-clé qui ont échoué et est bien plus fiable que la prose anglaise. Mettez le validateur compilé en cache au niveau du module plutôt que de recompiler à chaque requête. Pour chaque changement de schéma, couvrez un cas qui passe et un cas qui échoue — tester seulement le chemin heureux ne teste rien. Pour le choix du validateur et la conception des messages d'erreur, voir JSON Schema : des bases à la production ; pour brancher un schéma sur la génération de types, voir Synchronisation bidirectionnelle entre schéma et types TypeScript.

9. Questions fréquentes

$ref peut-il cohabiter avec type ou description ?

2020-12 autorise $ref à côté de mots-clés frères, et ces frères sont évalués normalement. En draft-07, les frères sont ignorés et seul $ref s'applique. C'est la plus grande différence de comportement quand un même schéma est utilisé entre plusieurs drafts, donc vérifiez quel draft votre validateur prend en charge avant de migrer.

Faut-il utiliser $defs ou definitions ?

Utilisez $defs pour les nouveaux travaux ; il est arrivé avec 2019-09. definitions n'est pas un mot-clé en 2020-12. Un pointeur comme $ref: "#/definitions/foo" peut encore se résoudre parce qu'il s'agit d'un simple JSON Pointer, mais le mode strict signalera un mot-clé inconnu. Mélanger les deux styles laisse une mine qui n'explose qu'en CI.

Pourquoi oneOf rejette-t-il des données qui semblent valides ?

Parce que les données satisfont deux branches à la fois. oneOf exige exactement une, et une branche qui ne contraint qu'une partie de l'objet est satisfaite même quand les champs de l'autre branche sont absents. Ajoutez un marqueur const à chaque branche pour les rendre mutuellement exclusives.

Les références récursives bouclent-elles à l'infini ?

Non. Les validateurs n'étendent pas les références à la compilation ; ils récursent sur les données réelles. Les vrais risques sont des données assez profondes pour faire déborder la pile et un ordre de résolution incorrect entre fichiers qui se référencent mutuellement. Une limite métier de profondeur sur les champs récursifs est plus efficace que n'importe quelle astuce de schéma.

La production peut-elle compter sur des $ref distants ?

Non. Les références distantes lient la disponibilité de la validation au réseau : changements de domaine, certificats expirés et isolation réseau interne cassent tous la chaîne. Regroupez les schémas référencés dans l'artefact ou enregistrez-les au démarrage si vous voulez un comportement prévisible.

Comment décider entre $defs et des fichiers séparés ?

Une structure qui apparaît à deux endroits peut être copiée une fois ; quand elle apparaît à un troisième endroit, promouvez-la dans $defs. Ce n'est que lorsqu'un autre langage ou un autre dépôt doit la lire qu'elle mérite un fichier autonome avec son propre $id.

Pourquoi les contraintes dans $defs n'ont-elles aucun effet ?

$defs n'est qu'un conteneur, et les nœuds qu'aucun $ref ne pointe ne participent jamais à la validation. Une liste required en tête de $defs sans référence entrante laisse quand même les données passer — c'est la spécification, pas un bug du validateur.

Faut-il mettre additionalProperties: false dans les branches allOf ?

Non. Chaque branche est évaluée indépendamment, donc une branche traite comme superflus les champs déclarés par une autre. L'approche 2020-12 déplace la politique de champs inconnus au niveau supérieur et utilise unevaluatedProperties: false, évalué une fois après toutes les branches.