Quand les erreurs JSON Schema n'ont aucun sens : localisation et gouvernance des messages Ajv
L’essentiel : Un validateur émet des erreurs lisibles par machine ; l'utilisateur veut savoir quel champ de saisie est faux. Couvre le tableau errors d'Ajv, instancePath contre schemaPath, le regroupement et la rédaction.

L'essentiel : lisez d'abord instancePath pour localiser les données fautives, puis schemaPath pour localiser la règle déclenchée ; pour required et additionalProperties, extrayez le vrai nom de clé depuis params avant de choisir le message.
Le travail du validateur est de dire quelle règle a échoué. Le traduire en « ce champ de saisie est faux » est votre travail.
1. Ce que le tableau errors vous donne réellement
Ajv ne lève pas d'exception en cas d'échec de validation ; il laisse le résultat dans validate.errors. La configuration par défaut ne conserve que la première erreur, et allErrors est ce qui produit une liste complète. Un objet d'erreur unique a cinq champs qui comptent, et les comprendre est la prémisse de tout le reste :
| Champ | Signification | Exemple |
|---|---|---|
instancePath |
JSON Pointer vers la valeur fautive | /email |
schemaPath |
Emplacement du mot-clé fautif dans le schéma | #/properties/email/format |
keyword |
Nom de la contrainte violée | format |
params |
Paramètres lisibles par machine de cette contrainte | { "format": "email" } |
message |
Prose anglaise qui change entre versions | must match format "email" |
Une erreur réelle ressemble à ceci :
[
{
"instancePath": "/email",
"schemaPath": "#/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
Notez que message est une prose écrite pour les humains, pas une interface stable. Ajv peut la reformuler entre versions, et elle ne peut pas servir un produit localisé. Ne la parsez jamais avec une expression régulière, et ne la renvoyez jamais telle quelle à un client.
2. instancePath et schemaPath répondent à des questions différentes
instancePath indique quelle tranche de données a échoué ; schemaPath indique quelle règle s'est déclenchée. Ce sont deux systèmes de coordonnées, et ils ne sont pas symétriques :
instancePathest un JSON Pointer RFC 6901. Une chaîne vide signifie la racine ;/items/0/pricesignifie le champ price du premier élément de tableau.schemaPathcommence par#et pointe le mot-clé dans le document de schéma. À travers un$ref, il pointe dans le schéma référencé, donc il vous faut le$idpour savoir de quel fichier il vient.- Une clé contenant
/ou~est échappée en~1et~0respectivement. Annulez cet échappement avant l'affichage, sinon l'interface montrera des symboles étranges.
Par exemple, instancePath: "/email" avec schemaPath: "#/properties/email/format" signifie que le champ email de l'objet racine n'a pas passé la vérification de format.
Le piège le plus courant concerne required et additionalProperties : leur instancePath pointe l'objet parent, pas la clé fautive.
| Mot-clé | instancePath pointe vers |
Où vit la vraie clé |
|---|---|---|
required |
L'objet parent (chaîne vide à la racine) | params.missingProperty |
additionalProperties |
L'objet parent | params.additionalProperty |
type |
La valeur fautive elle-même | — |
enum |
La valeur fautive elle-même | params.allowedValues |
Quand vous mappez les erreurs sur des champs de formulaire, traitez d'abord ces deux cas. Sinon « l'email est manquant » devient « erreur de l'objet racine », et l'utilisateur ne sait pas où taper.
3. allErrors et verbose : exhaustivité contre coût
Le défaut allErrors: false fait s'arrêter Ajv à la première défaillance. C'est bien pour un outil en ligne de commande et désastreux pour un formulaire : l'utilisateur corrige un champ, soumet, voit l'erreur suivante, et répète trois fois avant que le formulaire passe.
| Réglage | Comportement | Bénéfice | Coût |
|---|---|---|---|
allErrors: false |
Seulement la première erreur | Le plus rapide, mémoire minimale | Soumissions répétées |
allErrors: true |
Collecter toutes les erreurs | Tous les problèmes en une passe | Le nombre d'erreurs explose sur les schémas complexes |
verbose: true |
Attacher schema et data à chaque erreur |
Diagnostic serveur plus facile | Taille très accrue ; ne jamais l'exposer |
messages: false |
Supprimer le champ message |
Charges utiles plus petites | Perdre l'indice de débogage anglais |
Répartition recommandée : activez allErrors: true pour la validation serveur et conservez le tableau complet dans les journaux internes, tandis que la réponse publique ne porte que des champs en liste blanche. N'activez verbose que pendant l'investigation ; il incorpore le fragment de schéma correspondant et les données de l'utilisateur dans chaque erreur, ce qui coûte de la mémoire et peut faire fuiter des valeurs sensibles dans les journaux.
Le volume d'erreurs est l'autre problème courant. Une branche anyOf échouée émet plusieurs erreurs enfants plus une erreur de synthèse, et oneOf avec de nombreuses branches est pire. Affichez le tableau tel quel et les utilisateurs voient cinq messages quasi identiques pour un seul champ. Le regroupement doit se faire avant le rendu.
4. Regrouper les erreurs par champ
Le regroupement fait trois choses : retransformer le chemin en nom de champ, corriger le décalage de required et additionalProperties, et fusionner plusieurs erreurs sur le même champ en un seul message le plus actionnable.
/** One validation error ready for display */
interface FieldError {
field: string;
keyword: string;
params: Record<string, unknown>;
}
/**
* Group Ajv errors by field.
* @param errors Error array produced by Ajv
* @returns Map keyed by field path, each value listing its errors
*/
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
const grouped: Record<string, FieldError[]> = {};
for (const errorItem of errors) {
const keyword: string = errorItem.keyword;
let field: string = errorItem.instancePath;
if (keyword === 'required') {
const parentPath: string = errorItem.instancePath;
field = `${parentPath}/${errorItem.params.missingProperty}`;
} else if (keyword === 'additionalProperties') {
field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
}
const normalizedField = field === '' ? '(root)' : field;
(grouped[normalizedField] ??= []).push({
field: normalizedField,
keyword,
params: errorItem.params ?? {},
});
}
return grouped;
}
Fusionnez selon un ordre de priorité : required (manquant) d'abord, puis type, puis enum/const, puis format/pattern, puis longueur et plage. Affichez uniquement le message de plus haute priorité par champ et gardez le reste dans les journaux. La raison est simple : quand le type est faux, un message de longueur est du bruit, et corriger le type est la seule étape suivante sensée.
Les erreurs de synthèse de oneOf et anyOf ne portent généralement aucune information actionnable. Ignorez-les au rendu et ne gardez que les erreurs de branche qui pointent un champ concret.
5. Messages personnalisés : la voie du plug-in et la voie du gabarit
Ajv n'embarque aucune formulation localisée, donc deux options pratiques s'offrent à vous.
Option un : le plug-in ajv-errors. Il ajoute un mot-clé errorMessage pour que les messages vivent à côté des contraintes :
{
"properties": {
"email": {
"type": "string",
"format": "email",
"errorMessage": {
"format": "Please enter a valid email address",
"type": "Email must be a string"
}
}
}
}
L'avantage est que la formulation voyage avec le champ et que le schéma fait aussi office de documentation. L'inconvénient est que le texte est figé dans une langue, donc la localisation impose de maintenir plusieurs schémas. Le plug-in ne remplace aussi tous les messages que lorsque allErrors: true est défini ; oubliez ce drapeau et vous obtenez un mélange d'anglais personnalisé et par défaut.
Option deux : une table mot-clé vers gabarit. Le validateur ne fournit que keyword et params ; l'application produit la formulation :
| Mot-clé | Gabarit | Paramètres disponibles |
|---|---|---|
required |
Champ obligatoire manquant : {field} | missingProperty |
type |
Type attendu : {type} | type |
minLength |
Saisissez au moins {limit} caractères | limit |
maximum |
Ne doit pas dépasser {limit} | limit |
format |
Format invalide : {format} | format |
enum |
Doit être l'une des options autorisées | allowedValues |
pattern |
La valeur ne correspond pas au format attendu | pattern (ne pas le reproduire) |
L'option deux convient aux produits qui ont besoin de plusieurs langues et d'une voix cohérente. Elle sépare la validation de la présentation, garde la traduction hors du schéma, et permet au frontend de réutiliser les mêmes gabarits.
6. Localisation : les messages n'appartiennent pas au validateur
Déplacer les formulations dans des packs de langue est le point de départ de la gouvernance des messages. Un pack de langue stocke des gabarits et des clés, jamais des noms de champs ; les libellés de champs viennent de la configuration du formulaire ou du contrat d'API, et les deux sont combinés au rendu.
/** Validation message templates keyed by Ajv keyword */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
required: (params) => `Missing required field: ${params.missingProperty}`,
type: (params) => `Expected type: ${params.type}`,
minLength: (params) => `Enter at least ${params.limit} characters`,
maximum: (params) => `Must not exceed ${params.limit}`,
format: () => 'Invalid format',
};
/**
* Build one user-facing validation message.
* @param keyword Name of the failed constraint
* @param params Constraint parameters
* @returns Localized message text
*/
function buildMessage(keyword: string, params: Record<string, any>): string {
const template = VALIDATION_TEMPLATES[keyword];
return template ? template(params) : 'The value is invalid';
}
Trois règles comptent ici :
- Ne concaténez pas des phrases. « Email format is invalid » et son équivalent chinois ne partagent pas l'ordre des mots, et l'assemblage de chaînes finira par produire du grand n'importe quoi dans l'une des deux.
- Déléguez les pluriels et les classificateurs à la bibliothèque i18n. Ne codez jamais en dur « characters » dans un gabarit ;
1 characteret3 charactersdiffèrent. - Traduisez aussi les libellés de champs.
params.missingPropertyvautemail, tandis que l'interface devrait dire « Adresse e-mail ». Gardez cette correspondance dans la configuration du formulaire, pas dans le pack de langue.
7. Ne renvoyez pas la structure interne aux clients
Renvoyer schemaPath, params et message ensemble est pratique en développement et une fuite d'information en production. allowedValues révèle les énumérations internes, pattern révèle les expressions régulières de validation, et schemaPath révèle l'arborescence des fichiers et le nommage des champs — dans certains cas, cela donne aux attaquants une carte de vos points faibles.
La réponse publique garde exactement trois choses : un chemin de champ (ou un nom de champ de formulaire), un code d'erreur stable et un texte destiné à l'utilisateur. Utilisez des codes courts et stables au lieu du keyword brut, pour conserver la possibilité de changer de validateur plus tard.
{
"type": "about:blank",
"title": "Request validation failed",
"status": 422,
"errors": [
{ "field": "email", "code": "invalid_format", "message": "Please enter a valid email address" },
{ "field": "items[0].price", "code": "invalid_pattern", "message": "Amount must have exactly two decimals" }
]
}
Les journaux serveur doivent contenir l'inverse : le tableau d'erreurs complet, l'identifiant de requête et la version du schéma, pour qu'une investigation puisse répondre « quelle règle a bloqué ceci ». Évitez de vider des corps de requête entiers dans les journaux ; gardez plutôt les chemins et mots-clés fautifs.
8. Présentation frontend : du surlignage de champ aux marqueurs d'éditeur
Les erreurs finissent dans une interface, et la présentation dépend du contexte :
| Contexte | Présentation | Action clé |
|---|---|---|
| Formulaire | Texte rouge sous le champ, focus sur la première erreur | Mapper les chemins vers les noms de formulaire ; conserver les indices de tableau |
| Import de tableau en masse | Numéro de ligne, nom de colonne, résumé, exportable | Transformer /items/3/price en ligne 4, colonne prix |
| Éditeur JSON | Marqueurs en ligne et panneau de problèmes | Utiliser un parseur pour mapper le JSON Pointer en ligne et colonne |
| Outil de débogage d'API | Liste d'erreurs structurée conservant schemaPath |
Destiné aux développeurs, donc plus de détail est acceptable |
Trois notes d'implémentation :
- N'affichez que la première erreur par champ, en repliant le reste dans « 2 autres problèmes », pour que le formulaire ne se noie pas dans le rouge.
- Conservez les indices de tableau pour le ciblage mais supprimez-les dans les libellés. « Ligne 4, prix » se lit mieux que
/items/3/price. - Les scénarios d'éditeur exigent un mapping de positions. Une simple recherche de chaîne est peu fiable quand un nom de champ se répète ; le parseur devrait fournir des offsets pour chaque nœud, que vous convertissez ensuite en ligne et colonne.
Si le JSON vient d'une saisie utilisateur plutôt que d'un formulaire, confirmez d'abord que le document est syntaxiquement valide. Les erreurs de syntaxe surviennent au parsing et n'atteignent jamais la validation de schéma. Analyser et formater JSON signale la ligne et la colonne exactes, ce qui est bien plus rapide que de courir après des erreurs de schéma. Pour comprendre comment des schémas trop stricts apparaissent quand ils sont inférés à partir d'échantillons, voir Pourquoi un JSON Schema généré automatiquement rejette sans cesse des données réelles ; pour la configuration du validateur et le système de mots-clés, voir JSON Schema : des bases à la production.
9. Questions fréquentes
Pourquoi ne reçois-je qu'une seule erreur ?
allErrors vaut false par défaut, donc Ajv renvoie à la première défaillance. Activez-le explicitement pour la validation de formulaire ; sinon les utilisateurs doivent soumettre plusieurs fois pour découvrir chaque problème.
Pourquoi le chemin d'une erreur required est-il vide ?
instancePath pour required pointe l'objet parent, qui est la chaîne vide à la racine ; le nom de la clé manquante se trouve dans params.missingProperty. Concaténez les deux pour obtenir le chemin complet. Afficher instancePath seul donne l'inutile « erreur de l'objet racine ».
Puis-je afficher message directement aux utilisateurs ?
Déconseillé. C'est de la prose anglaise dont la formulation change entre versions, et elle n'a aucune capacité de localisation. Générez plutôt le texte à partir de keyword et params, et réservez message aux journaux.
Pourquoi mon message d'erreur personnalisé est-il ignoré ?
Vérifiez d'abord que allErrors: true est activé ; ajv-errors ne peut remplacer tous les messages qu'avec ce drapeau. Confirmez ensuite que errorMessage est au même niveau que le mot-clé fautif — un niveau d'écart et il ne sera pas reconnu.
Quels champs peut-on renvoyer sans risque en production ?
Le chemin de champ, un code d'erreur stable et un texte destiné à l'utilisateur. Gardez schemaPath, params et message dans les journaux serveur : cela soutient le débogage sans exposer énumérations internes, expressions régulières ou arborescence de fichiers.