Quando gli errori di JSON Schema non hanno senso: posizione degli errori Ajv e governance dei messaggi
In sintesi: Un validatore emette errori leggibili dalla macchina; gli utenti vogliono sapere quale casella di input è sbagliata. Copre l'array errors di Ajv, instancePath versus schemaPath, raggruppamento e formulazione.

In sintesi: leggi prima instancePath per localizzare il dato che fallisce, poi schemaPath per localizzare la regola che è scattata; per required e additionalProperties, estrai il vero nome della chiave da params prima di scegliere il messaggio.
Il compito del validatore è dire quale regola è fallita. Tradurlo in "questa casella di input è sbagliata" è compito tuo.
1. Cosa ti dà davvero l'array errors
Ajv non lancia un errore sul fallimento della validazione; lascia il risultato in validate.errors. La configurazione predefinita mantiene solo il primo errore, e allErrors è ciò che produce un elenco completo. Un singolo oggetto errore ha cinque campi che contano, e capirli è la premessa per tutto ciò che segue:
| Campo | Significato | Esempio |
|---|---|---|
instancePath |
JSON Pointer al valore che fallisce | /email |
schemaPath |
Posizione della parola chiave che fallisce nello schema | #/properties/email/format |
keyword |
Nome del vincolo violato | format |
params |
Parametri leggibili dalla macchina di quel vincolo | { "format": "email" } |
message |
Prosa inglese che cambia tra versioni | must match format "email" |
Un vero errore appare così:
[
{
"instancePath": "/email",
"schemaPath": "#/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
Nota che message è prosa scritta per gli umani, non un'interfaccia stabile. Ajv può riformularla tra versioni, e non può servire un prodotto localizzato. Non analizzarla mai con un'espressione regolare, e non restituirla mai alla lettera a un client.
2. instancePath e schemaPath rispondono a domande diverse
instancePath dice quale porzione dei dati è fallita; schemaPath dice quale regola è scattata. Sono due sistemi di coordinate, e non sono simmetrici:
instancePathè un JSON Pointer RFC 6901. Una stringa vuota significa la radice;/items/0/pricesignifica il campo price del primo elemento dell'array.schemaPathinizia con#e punta alla parola chiave dentro il documento dello schema. Attraverso un$ref, punta dentro lo schema referenziato, quindi necessiti dell'$idper sapere da quale file proviene.- Una chiave che contiene
/o~viene sottoposta a escape rispettivamente in~1e~0. Annulla quell'escape prima della visualizzazione, o l'interfaccia mostrerà simboli incomprensibili.
Per esempio, instancePath: "/email" insieme a schemaPath: "#/properties/email/format" significa che il campo email sull'oggetto radice non ha superato il controllo del formato.
La trappola più comune è required e additionalProperties: il loro instancePath punta all'oggetto padre, non alla chiave che crea il problema.
| Parola chiave | instancePath punta a |
Dove vive la vera chiave |
|---|---|---|
required |
L'oggetto padre (stringa vuota alla radice) | params.missingProperty |
additionalProperties |
L'oggetto padre | params.additionalProperty |
type |
Il valore che fallisce stesso | — |
enum |
Il valore che fallisce stesso | params.allowedValues |
Quando mappi gli errori sui campi del form, gestisci prima questi due casi. Altrimenti "email manca" diventa "errore dell'oggetto radice", e l'utente non ha idea di dove digitare.
3. allErrors e verbose: completezza versus costo
Il default allErrors: false fa fermare Ajv al primo fallimento. Va bene per uno strumento da riga di comando ed è disastroso per un form: l'utente corregge un campo, invia, vede l'errore successivo, e ripete tre volte prima che il form passi.
| Impostazione | Comportamento | Beneficio | Costo |
|---|---|---|---|
allErrors: false |
Solo il primo errore | Il più veloce, memoria minima | Invii ripetuti |
allErrors: true |
Raccoglie ogni errore | Tutti i problemi in un passaggio | Il numero di errori esplode su schema complessi |
verbose: true |
Allega schema e data a ogni errore |
Diagnosi lato server più facile | Dimensione molto aumentata; non esporla mai |
messages: false |
Elimina il campo message |
Payload più piccoli | Perdi l'indizio inglese di debug |
Divisione raccomandata: abilita allErrors: true per la validazione lato server e mantieni l'array completo nei log interni, mentre la risposta pubblica porta solo campi in whitelist. Attiva verbose solo durante l'indagine; incorpora il frammento di schema corrispondente e i dati dell'utente in ogni errore, il che costa memoria e può far trapelare valori sensibili nei log.
Il volume degli errori è l'altro problema comune. Un ramo anyOf fallito emette diversi errori figli più un errore di riepilogo, e oneOf con molti rami è peggio. Mostra l'array così com'è e gli utenti vedono cinque messaggi quasi duplicati per un campo. Il raggruppamento deve avvenire prima del rendering.
4. Raggruppare gli errori per campo
Il raggruppamento fa tre cose: trasforma il percorso in un nome di campo, corregge l'offset di required e additionalProperties, e unisce più errori sullo stesso campo nell'unico messaggio più azionabile.
/** 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;
}
Unisci con un ordine di priorità: required (mancante) per primo, poi type, poi enum/const, poi format/pattern, poi lunghezza e intervallo. Mostra solo il messaggio di priorità più alta per campo e tieni il resto nei log. La ragione è semplice: quando il tipo è sbagliato, un messaggio di lunghezza è rumore, e correggere il tipo è l'unico passo sensato successivo.
Gli errori di riepilogo di oneOf e anyOf di solito non portano informazioni azionabili. Saltali durante il rendering e mantieni solo gli errori dei rami che puntano a un campo concreto.
5. Messaggi personalizzati: la via del plugin e la via del template
Ajv non fornisce formulazione localizzata, quindi ci sono due opzioni pratiche.
Opzione uno: il plugin ajv-errors. Aggiunge una parola chiave errorMessage così i messaggi vivono accanto ai vincoli:
{
"properties": {
"email": {
"type": "string",
"format": "email",
"errorMessage": {
"format": "Please enter a valid email address",
"type": "Email must be a string"
}
}
}
}
Il vantaggio è che la formulazione viaggia con il campo e lo schema funge anche da documentazione. Lo svantaggio è che il testo è congelato in una lingua, quindi la localizzazione significa mantenere diversi schema. Il plugin inoltre sostituisce ogni messaggio solo quando allErrors: true è impostato; dimentica quel flag e ottieni un mix di messaggi personalizzati e inglese predefinito.
Opzione due: una tabella parola-chiave/template. Il validatore fornisce solo keyword e params; l'applicazione produce la formulazione:
| Parola chiave | Template | Parametri disponibili |
|---|---|---|
required |
Campo obbligatorio mancante: {field} | missingProperty |
type |
Tipo atteso: {type} | type |
minLength |
Inserisci almeno {limit} caratteri | limit |
maximum |
Non deve superare {limit} | limit |
format |
Formato invalido: {format} | format |
enum |
Deve essere una delle opzioni consentite | allowedValues |
pattern |
Il valore non corrisponde al formato atteso | pattern (non ripeterlo) |
L'opzione due si adatta ai prodotti che necessitano più lingue e una voce coerente. Separa la validazione dalla presentazione, tiene la traduzione fuori dallo schema, e permette al frontend di riusare gli stessi template.
6. Localizzazione: i messaggi non appartengono al validatore
Spostare la formulazione nei language pack è il punto di partenza della governance dei messaggi. Un language pack memorizza template e chiavi, mai nomi di campi; le etichette dei campi vengono dalla configurazione del form o dal contratto API, e i due si combinano al momento del rendering.
/** 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';
}
Tre regole contano qui:
- Non concatenare frasi. "Il formato email è invalido" e il suo equivalente italiano non condividono l'ordine delle parole, e l'assemblamento di stringhe finirà per produrre nonsenso in una delle due.
- Delega plurali e classificatori alla libreria i18n. Non codificare mai "caratteri" in un template;
1 caratteree3 caratteridifferiscono. - Traduci anche le etichette dei campi.
params.missingPropertyèemail, mentre l'interfaccia dovrebbe dire "Indirizzo email". Mantieni quella mappatura nella configurazione del form, non nel language pack.
7. Non restituire la struttura interna ai client
Restituire insieme schemaPath, params e message è comodo durante lo sviluppo e una fuga di informazioni in produzione. allowedValues rivela enum interni, pattern rivela regex di validazione, e schemaPath rivela layout dei file e denominazione dei campi — in alcuni casi consegnando agli attaccanti una mappa dei tuoi punti deboli.
La risposta pubblica mantiene esattamente tre cose: un percorso di campo (o nome di campo del form), un codice di errore stabile, e testo rivolto all'utente. Usa codici brevi e stabili invece del keyword grezzo così mantieni spazio per cambiare validatore in seguito.
{
"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" }
]
}
I log del server dovrebbero contenere l'opposto: l'array completo degli errori, l'id della richiesta e la versione dello schema, così un'indagine può rispondere "quale regola ha bloccato questo". Evita di versare interi corpi di richiesta nei log; mantieni invece i percorsi e le parole chiave che falliscono.
8. Presentazione frontend: dall'evidenziazione del campo ai marker dell'editor
Gli errori finiscono eventualmente in un'interfaccia, e la presentazione dipende dal contesto:
| Contesto | Presentazione | Azione chiave |
|---|---|---|
| Form | Testo rosso sotto il campo, focus sul primo errore | Mappa i percorsi ai nomi del form; mantieni gli indici di array |
| Import massivo di tabelle | Numero di riga, nome di colonna, riepilogo, esportabile | Trasforma /items/3/price in riga 4, colonna price |
| Editor JSON | Marker inline e pannello dei problemi | Usa un parser per mappare JSON Pointer a riga e colonna |
| Strumento di debug API | Elenco strutturato di errori che conserva schemaPath |
Rivolto agli sviluppatori, quindi più dettaglio è accettabile |
Tre note implementative:
- Mostra solo il primo errore per campo, raggruppando il resto in "altri 2 problemi", così il form non annega nel rosso.
- Mantieni gli indici di array per il targeting ma eliminali nelle etichette. "Riga 4, prezzo" si legge meglio di
/items/3/price. - Gli scenari di editor necessitano mappatura delle posizioni. Una semplice ricerca di stringa non è affidabile quando un nome di campo si ripete; il parser dovrebbe fornire gli offset per ogni nodo, che poi converti in riga e colonna.
Se il JSON proviene da un utente che digita invece che da un form, conferma prima che il documento sia sintatticamente valido. Gli errori di sintassi avvengono durante il parsing e non raggiungono mai la validazione dello schema. Parsing e formattazione JSON riporta la riga e la colonna esatte, il che è molto più veloce che inseguire gli errori di schema. Per come nascono gli schema troppo rigidi quando vengono inferiti da campioni, vedi Perché lo JSON Schema generato automaticamente continua a rifiutare i dati reali; per la configurazione del validatore e il sistema delle parole chiave, vedi JSON Schema: dalle basi alla produzione.
9. Domande frequenti
Perché ottengo solo un errore?
allErrors è false per impostazione predefinita, quindi Ajv restituisce al primo fallimento. Attivalo esplicitamente per la validazione dei form; altrimenti gli utenti devono inviare ripetutamente per scoprire ogni problema.
Perché il percorso su un errore required è vuoto?
instancePath per required punta all'oggetto padre, che è la stringa vuota alla radice; il nome della chiave mancante sta in params.missingProperty. Concatena i due per ottenere il percorso completo. Mostrare instancePath da solo produce l'inutile "errore dell'oggetto radice".
Posso mostrare message direttamente agli utenti?
Non è consigliabile. È prosa inglese la cui formulazione cambia tra versioni, e non ha capacità di localizzazione. Genera il testo da keyword e params e riserva message ai log.
Perché il mio messaggio di errore personalizzato viene ignorato?
Controlla prima che allErrors: true sia abilitato; ajv-errors può sostituire tutti i messaggi solo con quel flag. Poi conferma che errorMessage stia allo stesso livello della parola chiave che fallisce — un livello sbagliato e non verrà riconosciuto.
Quali campi sono sicuri da restituire in produzione?
Percorso di campo, codice di errore stabile e testo rivolto all'utente. Mantieni schemaPath, params e message nei log del server: supporta il debug senza esporre enum interni, regex o layout dei file.