JSON Schema : des bases à la production
L’essentiel : Les mots-clés sont nombreux, mais une douzaine seulement servent au quotidien. Bien maîtriser type/properties/required/$ref, bien gérer les messages d'erreur, et vous couvrez quatre-vingts pour cent des cas réels.

L'essentiel : fixez d'abord $schema sur 2020-12, puis extrayez les structures communes avec $defs + $ref ; n'utilisez oneOf que lorsqu'exactement une branche doit correspondre, et préférez les discriminateurs ou anyOf partout ailleurs.
Un schéma n'est pas une annexe de la documentation ; c'est un actif qui peut être exécuté, testé et généré.
1. Mettre d'abord de l'ordre dans les versions
| Version | Valeur de $schema |
Statut | Remarques |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
Obsolète | Projets hérités et certains outils OpenAPI 2.0 |
| draft-07 | http://json-schema.org/draft-07/schema# |
Très répandu | Base de facto d'OpenAPI 3.0 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
Disponible | Introduit $defs et le vocabulaire d'annotation |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
Recommandé actuellement | Introduit prefixItems, modifie la sémantique de items |
Utilisez 2020-12 pour les nouveaux projets. Notez qu'en 2020-12 items ne contraint que les « éléments restants » — les déclarations de type tuple sont passées à prefixItems.
C'est le piège le plus courant lors d'une migration depuis draft-07.
2. Un schéma minimal utilisable
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/user.json",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1, "maxLength": 64 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
},
"additionalProperties": false
}
Ce schéma couvre les quatre préoccupations les plus courantes : contraintes de type, champs obligatoires, limites de plage et contraintes sur les éléments de tableau.
3. Carte des mots-clés
3.1 Général
| Mot-clé | Rôle | Attention |
|---|---|---|
type |
Le type ; peut être un tableau | "type": ["string", "null"] signifie nullable |
enum |
Valeurs énumérées | Les valeurs doivent être uniques |
const |
Contrainte sur une valeur unique | Utile pour des valeurs fixes comme un numéro de version |
$ref |
Référence un autre schéma | Référence le schéma entier et ne fusionne pas avec les mots-clés frères de l'objet référençant |
3.2 Nombres
multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum.
Notez qu'en draft-04 exclusiveMaximum était un booléen (utilisé avec maximum) ; ce n'est qu'à partir de draft-07 qu'il est devenu un mot-clé numérique autonome —
ce qui fait échouer en mode strict beaucoup de « schémas copiés de vieilles docs ».
3.3 Chaînes
minLength, maxLength, pattern (une expression régulière ECMA-262), et format (une annotation).
minLength compte les points de code Unicode, ni les unités UTF-16 ni les octets. Valider avec string.length en JavaScript donnera un résultat différent.
3.4 Tableaux
| Mot-clé | Rôle |
|---|---|
prefixItems |
La contrainte de type tuple de 2020-12, faisant correspondre les éléments un par un par index |
items |
Une contrainte uniforme sur les éléments restants (2020-12) |
contains + minContains/maxContains |
Au moins/au plus combien d'éléments correspondants doivent être présents |
uniqueItems |
Les éléments doivent tous différer (comparaison profonde ; à éviter sur les grands tableaux) |
minItems / maxItems |
Contraintes de longueur |
3.5 Objets
properties, required, patternProperties, additionalProperties, propertyNames,
minProperties, maxProperties, dependentRequired, dependentSchemas.
Trois idées fausses très fréquentes :
requiredexige seulement que la clé existe :{"name": ""}est valide. Pour rejeter une chaîne vide, il faut ajouterminLength: 1;additionalProperties: falseamplifie les problèmes de compatibilité : un champ superflu envoyé par un client fait échouer toute la charge utile, donc faire évoluer l'API oblige chaque appelant à mettre à jour en même temps ;propertiesne restreint pas les clés non déclarées — par défaut elles sont autorisées.
3.6 Composition logique
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| Mot-clé | Sémantique | Échec typique |
|---|---|---|
allOf |
Tous doivent être satisfaits | Plusieurs branches imposent des contraintes contradictoires sur le même champ |
anyOf |
Au moins un doit être satisfait | Le message d'erreur est difficile à rattacher à une branche précise |
oneOf |
Exactement un doit être satisfait | Les données correspondent à deux branches et échouent, alors qu'elles « semblent manifestement correctes » |
not |
Ne doit pas être satisfait | Rarement utilisé, et messages d'erreur les moins conviviaux |
oneOf est le mot-clé le plus susceptible de produire des « données valides qui échouent à la validation ». Si les contraintes de branches se chevauchent (par exemple, les deux autorisent des champs supplémentaires),
un même enregistrement correspond à plusieurs branches et oneOf échoue d'emblée. Dans ce cas, passez à anyOf, ou utilisez un champ discriminateur avec const dans les branches.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Le style discriminateur est plus lisible que oneOf, et produit de meilleures erreurs.
4. Découpage et réutilisation
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
"address": {
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
}
},
"type": "object",
"required": ["total", "shipTo"],
"properties": {
"total": { "$ref": "#/$defs/money" },
"shipTo": { "$ref": "#/$defs/address" }
}
}
$defsest la forme introduite en 2019-09 ; draft-07 utilisedefinitions;- Les références entre fichiers utilisent des
$idrelatifs, mais ne comptez pas sur la récupération de schémas distants à l'exécution en production — regroupez-les plutôt comme fichiers statiques ; - Vous ne pouvez pas ajouter de contraintes à côté d'une référence (le comportement de
$refavec les mots-clés frères varie selon la version). Quand il faut composer, utilisezallOf.
5. Validation à l'exécution avec Ajv
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({
allErrors: true,
strict: true,
allowUnionTypes: true,
});
addFormats(ajv);
const validateOrder = ajv.compile(orderSchema);
export function assertOrder(input: unknown): void {
if (validateOrder(input)) {
return;
}
const detail = (validateOrder.errors ?? []).map((errorItem) => ({
path: errorItem.instancePath || '/',
keyword: errorItem.keyword,
message: errorItem.message,
params: errorItem.params,
}));
throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}
Points clés :
- Appelez
compileune seule fois, au niveau supérieur du module ; recompiler à chaque requête transforme une validation de 10 ms en centaines de millisecondes ; strict: truefait apparaître tôt les mots-clés mal orthographiés (par exempleminlengthau lieu deminLength), donc cela vaut la peine de l'activer ;- Seul
ajv-formatsfait réellement entrer en vigueurformat: "email"etformat: "date-time"; - 2020-12 exige le point d'entrée
ajv/dist/2020— n'utilisez pas l'export par défaut.
6. Comment concevoir les messages d'erreur
Ce que le validateur vous donne, c'est « quel mot-clé a échoué » ; ce que l'utilisateur veut, c'est « quel champ de saisie est faux ». Il vaut la peine d'ajouter une couche de correspondance :
const FIELD_LABEL: Record<string, string> = {
'/name': 'Name',
'/age': 'Age',
'/tags': 'Tags',
};
/**
* Convert Ajv errors into display-ready messages.
* @param errors The list of Ajv errors
* @returns An array of display messages
*/
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
return errors.map((errorItem) => {
const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
if (errorItem.keyword === 'required') {
const missing = (errorItem.params as { missingProperty: string }).missingProperty;
return `${label} is missing the required field ${missing}`;
}
if (errorItem.keyword === 'minLength') {
return `${label} is too short`;
}
return `${label} failed validation (${errorItem.keyword})`;
});
}
Côté API, conservez une structure lisible par machine pour que le frontend puisse décider de la présentation :
{
"type": "https://bbdrug.com/problems/validation",
"title": "Request data validation failed",
"status": 422,
"errors": [
{ "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
]
}
7. Performances et pièges
- Le coût de validation croît avec la complexité du schéma et la taille des données ; plusieurs branches
oneOfprovoquent une explosion combinatoire, très perceptible avec des milliers d'enregistrements ; uniqueItemseffectue une comparaison profonde en O(n²) sur le tableau ; pour les grands tableaux, dédupliquez plutôt dans la couche métier ;- Évitez les motifs à retour arrière catastrophique dans
pattern(comme(a+)+$) ; c'est le point d'entrée classique du ReDoS ; - Quand
additionalProperties: falseest combiné avecallOf, les champs déclarés dans un sous-schéma sont jugés « supplémentaires » par le parent, donc il faut les fusionner en un seul schéma ou utiliserunevaluatedProperties.
8. Liste de vérification
- [ ] Fixer 2020-12 et déclarer
$schemaen tête de fichier ; - [ ] Extraire les structures communes avec
$defs+$refau lieu de copier-coller ; - [ ] Préférer les discriminateurs à
oneOf; - [ ] Réutiliser le résultat de
compiledu validateur et activer le mode strict ; - [ ] Documenter clairement si
formatest appliqué ; - [ ] Les réponses d'erreur fournissent le chemin, le mot-clé et un message destiné à l'utilisateur ;
- [ ] Mettre le schéma dans la CI : exécuter les cas de la suite de tests JSON Schema comme tests de régression.
FAQ
Comment choisir un draft JSON Schema ?
Utilisez 2020-12 pour les nouveaux projets et déclarez $schema en tête de fichier. Si le schéma entre dans OpenAPI 3.1, il doit aussi être en 2020-12, car 3.1 intègre directement cette version ; ne gardez draft-07 que pour les projets encore liés à d'anciennes chaînes d'outils. Les deux se comportent différemment pour items et pour les mots-clés frères de $ref, donc ne les mélangez pas dans un même ensemble de schémas.
Pourquoi additionalProperties: false amplifie-t-il les problèmes de compatibilité ?
Il traite toute clé non déclarée comme une erreur, donc un champ superflu envoyé par un client fait échouer toute la charge utile. Chaque appelant doit alors se mettre à jour en même temps dès que l'API évolue, sinon les anciens clients sont rejetés. Les schémas de type configuration devraient fermer explicitement les champs superflus, tandis que les API publiques doivent peser le coût de compatibilité.
Faut-il utiliser oneOf ou anyOf ?
N'utilisez oneOf que lorsque les données doivent correspondre à exactement une branche. Si les contraintes de branches peuvent être vraies simultanément, un même enregistrement correspond à plusieurs branches et oneOf échoue d'emblée ; passez à anyOf, ou ajoutez un discriminateur const dans chaque branche.