Perché lo JSON Schema generato automaticamente continua a rifiutare i dati reali
In sintesi: Gli schema generati spesso bloccano dati validi. Scopri perché campioni più ricchi producono schema peggiori, poi il flusso di lavoro: campionamento multi-record, completamento semantico, localizzazione degli errori.

In sintesi: fai convergere a mano prima il confine required/optional — fornisci 5–20 record di forme diverse con "tutto obbligatorio" disattivato — poi genera lo schema e aggiungi manualmente format, enum e additionalProperties; un singolo campione con tutti i campi sarà sempre troppo rigido.
Il generatore può vedere solo il singolo record che hai incollato. Legge "questo record ha un'email" come "ogni record deve avere un'email", quindi più i tuoi campioni sono completi, più diventa esigente.
1. Un generatore fa mappatura strutturale, non comprensione del business
Dai un pezzo di JSON a un generatore e fa esattamente una cosa: percorre la struttura dei valori e traduce ogni nodo in parole chiave dello schema. L'intera tabella di mappatura è fissa:
| Valore nel campione | Tipo inferito | Note |
|---|---|---|
"hello" |
{"type": "string"} |
Non distingue email, data, UUID |
42 |
{"type": "number"} |
Un intero non viene inferito come integer |
3.14 |
{"type": "number"} |
Come sopra |
true |
{"type": "boolean"} |
— |
null |
{"type": "null"} |
Riconosce solo null, mai "nullable" |
[1, "a"] |
{"type": "array", "items": {"type": ["number", "string"]}} |
I tipi degli elementi vengono uniti |
{"a": 1} |
{"type": "object", "properties": {...}, "required": [...]} |
required è il punto cruciale qui |
Tutti i problemi sono nell'ultima riga. required non deriva dai tipi; deriva da "questa chiave esiste in questo campione". Quando il campione è un singolo record, quella affermazione equivale a "ogni chiave è obbligatoria".
E i dati restituiti dalle API reali hanno quasi sempre metà dei campi opzionali. È qui che inizia il conflitto.
2. Cinque record della stessa forma, cinque tassi di accettazione
Partiamo dalle misure. Chiunque guardi i cinque record sotto direbbe "queste sono della stessa forma":
[
{
"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 compare in 3 dei 5 record, admin compare solo una volta, e il record 2 ha un array tags vuoto.
Ora genera uno schema da ciascun record individualmente, poi usa ciascun risultato per validare tutti e cinque i record (validatore: ajv 8):
| Generato da | Numero di chiavi | required generato |
Accetta su 5 |
|---|---|---|---|
| Record 1 | 5 | id, name, email, tags, score |
3 record |
| Record 2 | 4 | id, name, tags, score |
5 record |
| Record 3 | 5 | id, name, email, tags, score |
3 record |
| Record 4 | 5 | id, name, email, tags, score |
1 record |
| Record 5 | 6 | id, name, email, tags, score, admin |
1 record |
Nessuno dei cinque schema è corretto, ma l'unico che accetta tutti i dati viene dal record 2 — il record che manca di email. Vince per una ragione semplice: le chiavi che non compaiono mai non vengono mai scritte in required.
Il record 4 è ancora più istruttivo: il suo valore email è null, quindi il generatore codifica {"type": "null"}, e gli altri quattro record con email stringa vengono tutti rifiutati.
3. Perché "incolla il record più completo" è la scelta peggiore
L'intuizione è "incolla il campione con il maggior numero di campi e lo schema sarà completo". Le misure mostrano l'esatto contrario — e una terza parte ha eseguito un test controllato più ampio.
absurdtools ha usato 3000 "famiglie di dati" casuali, 8 record per famiglia, con ogni campo opzionale presente circa metà delle volte, e ha misurato quanti dati poteva accettare uno schema generato da campioni con diversi conteggi di chiavi:
| Numero di chiavi del campione | Quota dei dati della famiglia accettata |
|---|---|
| 2 | 100,0% |
| 3 | 56,8% |
| 4 | 34,8% |
| 5 | 23,4% |
| 6 | 17,7% |
| 7 | 14,9% |
La conclusione non è "un leggero declino" ma un'inversione completa: il campione minimale ha prodotto lo schema più permissivo nel 95% delle famiglie, mentre il campione più completo lo ha fatto solo nello 0,1% .
La ragione sta nella semantica di required, che significa "tutti"; l'opzionalità può essere osservata solo attraverso l'assenza. Incollare un record con ogni campo dice al generatore "nemmeno una di queste chiavi può essere omessa"; incollare un record con un campo mancante gli fornisce la prova che "questa chiave può essere assente".
Ricordalo in una frase: il valore di un campione non è la completezza, ma quali assenze copre.
Quanti record bastano
Lo stesso lotto di esperimenti ha anche misurato la velocità di convergenza: accumulando record uno per uno sotto la regola "una chiave resta required solo se ogni record finora la ha", il numero mediano di record perché required converga all'insieme vero è 3; 6 record coprono circa il novanta per cento delle famiglie, e 12 le coprono tutte.
Quindi campionare 5–10 record giorno per giorno è il miglior rapporto valore/sforzo; forzarne venti o più produce pochissimo extra.
Ma c'è una precondizione: questi record devono provenire da situazioni genuinamente diverse. Cinque record copiati dallo stesso template smettono di fornire nuove informazioni dal secondo in poi — i loro insiemi di campi sono identici, e required non può restringersi di una singola chiave.
4. La correzione: fornisci più record insieme e lascia parlare le assenze
Poiché il campionamento a record singolo è necessariamente troppo rigoroso, l'approccio giusto è lasciare che il generatore guardi più record insieme e faccia convergere required con la regola "required solo se ogni record lo ha".
Il generatore di questo sito accetta direttamente un array e ha un interruttore "tutto obbligatorio". Disattivalo e decide per conteggio di occorrenze: solo le chiavi portate da ogni record vengono scritte in required.
Ancora quei cinque record — questa volta combinali in un array e inferisci insieme, poi prendi items come contratto dell'elemento:
| Record forniti | required inferito |
Validazione di tutti e 5 |
|---|---|---|
| 1 record | id, name, email, tags, score |
3 passano |
| 2 record | id, name, tags, score |
4 passano |
| 3 record | id, name, tags, score |
4 passano |
| 4 record | id, name, tags, score |
5 passano |
| 5 record | id, name, tags, score |
5 passano |
required converge appena viene aggiunto il quarto record, e non cambia più. email e admin vengono correttamente collocati in properties ma non in required. E poiché l'email del record 4 è null, l'inferenza multi-record la combina in {"type": ["string", "null"]}, quindi sia stringhe sia null passano — cosa che il campionamento a record singolo non può mai fare.
Questo è lo stesso ordine di grandezza della scoperta di absurdtools secondo cui "required converge dopo una mediana di 3 record".
L'inferenza multi-record ha i propri punti ciechi, però: se l'email in questi record capita di essere null in ognuno (diciamo che vengono tutti da account senza profilo compilato), il risultato dell'inferenza diventa {"type": "null"}, e le email stringa vengono ancora rifiutate. Allo stesso modo, se ogni valore tags è un array vuoto, il tipo degli elementi non può essere inferito affatto.
Quindi il campionamento ha un ulteriore requisito hard: ogni forma di ogni campo deve comparire almeno una volta. Porta una stringa, un numero, un null e un array vuoto; non lasciare che una forma sia del tutto assente.
Concretamente:
- Estrai 5–20 risposte da log, mock o richieste reali, e includi sicuramente le righe anomale (quella con campi vuoti, quella con null, quella con array vuoto);
- Assemblale in un array JSON e incollalo in il generatore;
- Disattiva "tutto obbligatorio" e scegli draft 2020-12;
- Il risultato è
type: arraypiù unitems; prendi la sezioneitemscome contratto per un singolo record; - Valida immediatamente i campioni originali con questo schema — il 100% deve passare. Se anche solo uno fallisce, il tuo campionamento non basta; torna indietro e aggiungi record.
Il passo 5 è l'autocontrollo più economico. Se il generatore non riesce nemmeno ad accogliere i dati che gli hai fornito, il tuo metodo di campionamento è sbagliato, e non ha senso andare oltre.
5. Quattro cose che un generatore non può riempire — devi scriverle a mano
Anche dopo aver fornito più record, le seguenti cose non possono ancora essere inferite e devono essere aggiunte manualmente:
| Pezzo mancante | Comportamento misurato | Conseguenza | Come correggere |
|---|---|---|---|
| Formato della stringa | "a@b.com" produce solo {"type": "string"} |
Validare "not an email" passa |
Aggiungi "format": "email" e abilita le asserzioni di formato |
| Intervalli ed enum | Non vede che score è limitato a 0–100, né che status ha solo tre valori |
Numeri negativi, valori enormi e valori enum spazzatura passano tutti | Aggiungi minimum / maximum / enum |
| Politica sui campi sconosciuti | additionalProperties non viene generato |
Una chiave con errore di battitura viene accettata silenziosamente, il che è più pericoloso di un errore | Aggiungi "additionalProperties": false per i file in stile configurazione |
| Semantica di nullability e collezioni vuote | Un singolo campione che contiene null si blocca su {"type": "null"}; un array vuoto produce "items": {} |
Valori validi vengono rifiutati, oppure gli elementi dell'array sono completamente non vincolati | Cambia in ["string", "null"], e dai agli array vuoti elementi reali |
La terza riga merita una menzione separata: il valore predefinito di additionalProperties è "consenti". Questo significa che un file di configurazione che scrive timeout come timeuot passa la validazione e poi non fa silenziosamente nulla a runtime. Per gli schema in stile configurazione, è una voce che devi esplicitamente disattivare.
format ha anch'esso una trappola: dalla draft 2019-09, format appartiene al vocabolario delle annotazioni, e per impostazione predefinita annota soltanto invece di imporre. Se hai aggiunto format: "email" e i valori illegali passano ancora, in otto casi su dieci il validatore non ha abilitato validateFormats o il plugin del formato corrispondente — non è che lo schema sia sbagliato.
Una nota sulla scelta del draft mentre siamo qui: se lo schema va in OpenAPI 3.1, deve essere draft 2020-12, perché la 3.1 incorpora direttamente quella versione; le toolchain più vecchie (l'ecosistema Swagger 2.0 e alcune implementazioni $ref) sono ancora centrate su draft-07. I due si comportano diversamente per le parole chiave sorelle di $ref e per le parole chiave relative agli array, quindi non mescolarli dentro uno stesso insieme di schema.
6. Per le API di terze parti non documentate, ricostruisci il contratto dai campioni di risposta
Integrare un callback di terze parti, lavorare con un partner che non scrive documentazione, o mantenere un sistema legacy di dieci anni — tutto questo significa "i dati sono in mano, la specifica no". Qui un generatore non è soltanto una comodità; è il percorso più veloce verso un contratto.
Il metodo è lo stesso di sopra, con due "devi" extra:
Uno: campiona nel tempo e attraverso gli stati. I campi di terze parti spesso cambiano con lo stato di business — una promozione aggiunge un discount, un ordine rimborsato aggiunge un refundAt, e una risposta di errore non ha affatto data. Cogliere un singolo snapshot significa vedere un solo stato.
Fornisci insieme qualche successo, qualche fallimento e qualche risultato vuoto: error e data spesso compaiono mutuamente esclusivi, e l'inferenza multi-record li mette entrambi in properties lasciandoli fuori da required — esattamente come in realtà.
Due: archivia lo schema nel repository e allega un controllo giornaliero. Valida le risposte reali di oggi contro di esso, e verrai a sapere di campi che spariscono silenziosamente o cambiano tipo prima dei tuoi utenti.
// 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,
);
}
Il valore di questa pratica non è "risparmiare lo sforzo di scriverlo a mano", ma trasformare un accordo verbale in un avviso eseguibile. Una terza parte non ti dice mai in anticipo che ha cambiato un campo, ma il cambiamento comparirà sicuramente nei tuoi log.
Un confine da tracciare chiaramente: lo schema ricostruito descrive ciò che l'altra parte restituisce ora, non ciò che promette di restituire. Il primo serve al monitoraggio; solo il secondo appartiene a un contratto o alla documentazione API.
7. Dopo il completamento, fai sì che gli errori di validazione puntino a campi specifici
Una volta che lo schema è utilizzabile, la preoccupazione successiva sono errori leggibili. Un oggetto errore ajv ha cinque campi, e i primi quattro sono quelli utili:
| Campo | Significato | Esempio (id: 0 che viola minimum: 1) |
|---|---|---|
instancePath |
JSON Pointer al valore incriminato nei dati | "/id" |
schemaPath |
Posizione della regola che scatta nello schema | "#/properties/id/minimum" |
keyword |
Nome del vincolo fallito | "minimum" |
params |
Dettagli leggibili dalla macchina di quel vincolo | { "comparison": ">=", "limit": 1 } |
message |
Prosa inglese che cambia tra versioni, non analizzarla | "must be >= 1" |
Un'asimmetria è la più facile in cui inciampare: quando required fallisce, instancePath punta all'oggetto padre (di solito la stringa vuota, cioè la radice), non al campo mancante. Il nome della chiave mancante è in params.missingProperty. Allo stesso modo, quando additionalProperties fallisce, il nome della chiave extra è in params.additionalProperty.
Quindi quando mappi gli errori su "campo → messaggio", gestisci prima questi due casi speciali:
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 non è opzionale. Nella configurazione predefinita ajv si ferma al primo errore, quindi l'utente corregge un campo, invia di nuovo, ottiene l'errore successivo, e necessita tre viaggi di andata e ritorno solo per riempire un form. Se usi anche ajv-errors per messaggi personalizzati, senza questo flag il plugin sostituirà silenziosamente solo alcuni messaggi.
8. Un processo di rollout riutilizzabile
Metti in fila i passi sopra in una routine fissa e seguila ogni volta che integri una nuova API:
- Campiona: prendi 5–20 risposte reali, scegliendo deliberatamente quelle con campi vuoti, null, array vuoti e campi extra;
- Inferisci: incollale insieme come array in il generatore, disattiva "tutto obbligatorio", e prendi
items; - Completa la semantica: aggiungi
format,enum,minimum/maximum,minItemseadditionalPropertiesun elemento alla volta; - Autocontrollo: i campioni originali devono passare al 100%, altrimenti torna al passo 1 e aggiungi campioni;
- Prova negativa: prepara un input "dovrebbe fallire" per ogni vincolo e conferma che fallisca davvero — testare solo il percorso che passa equivale a non testare;
- Committa: metti lo schema nel controllo di versione, mantieni i campioni che falliscono come casi di test, ed esegui i test per primi ogni volta che l'API cambia un campo.
Il test negativo del passo 5 è il più facile da saltare. Aggiungere una chiave a required senza mai verificare che una chiave mancante dia errore significa scommettere che il validatore sia d'accordo con il tuo modello mentale.
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);
});
});
Quell'ultima asserzione conta. Asserire solo false può restare verde anche dopo che lo schema è stato rotto — il fallimento può ormai derivare da una causa del tutto diversa.
9. FAQ
Lo schema prodotto da un generatore può andare direttamente in produzione
No. È solo uno snapshot della struttura. Devi almeno aggiungere formati, intervalli, enum e una politica sui campi sconosciuti, e poi far passare sia i campioni originali sia quelli negativi. Il generatore risolve "digitare a mano properties è tedioso", non "decide le regole di business".
Per il sistema delle parole chiave nella redazione dello schema, la divisione con $ref e le parole chiave di composizione, vedi JSON Schema: dalle basi alla produzione.
Perché il record con più campi rifiuta più dati
Perché required viene raccolto da "ciò che è apparso nel campione". Un campione con tutti i campi trasforma ogni chiave opzionale in una obbligatoria, mentre la maggior parte di quelle chiavi è assente nei dati reali. Fornire più record, o un record che omette deliberatamente campi, ti avvicina alla verità più del campione più completo.
Un intero verrà inferito come integer
Il generatore di questo sito non lo fa: sia gli interi sia i float vengono emessi come number, quindi uno schema generato da {"price": 5} accetta 5.99. Questo evita incidenti come "il campione era per caso un intero, quindi la produzione rifiuta i prezzi con decimali". Alcuni strumenti di terze parti inferiscono davvero integer dal valore, quindi sii particolarmente attento con i campi monetari.
Per dettagli sulla precisione dei numeri e i confini dei tipi, vedi I sei tipi di dati JSON e le trappole della precisione numerica.
Ho aggiunto format ma non ha effetto
Dalla draft 2019-09, format è solo annotazione per impostazione predefinita. O abiliti esplicitamente le asserzioni di formato nel validatore (ajv necessita di ajv-formats), oppure usi pattern per un vincolo rigido. Non assumere che aggiungerlo blocchi qualcosa.
required riporta un errore, ma il campo è chiaramente presente nei dati
Guarda prima instancePath. Un errore required punta all'oggetto padre, il che significa che la chiave mancante non è a quel livello — cause comuni sono un livello di annidamento sbagliato (/profile/name non è lo stesso di name alla radice), una differenza di maiuscole (userId versus userID), o la serializzazione a monte che elimina del tutto undefined.
Prima di indagare, conferma che il documento stesso sia JSON valido: commenti, virgole finali e problemi simili falliscono allo stadio di parsing, prima che lo schema abbia mai una possibilità. Parsing e formattazione JSON punta direttamente alla riga incriminata.
Quanti record dovrei campionare, e quale draft dovrei usare?
Campionare 5–10 record giorno per giorno dà il miglior valore; required converge a una mediana di 3 record, e 12 record coprono ogni famiglia. Ogni record deve provenire da una situazione genuinamente diversa, e ogni forma di ogni campo deve comparire almeno una volta. Fissa il draft alla 2020-12, perché OpenAPI 3.1 accetta solo quella versione.
Perché un default permissivo di additionalProperties è più pericoloso?
Il default non emette affatto la parola chiave, quindi una chiave con errore di battitura viene accettata silenziosamente, passa la validazione, e poi fallisce a runtime — più difficile da diagnosticare di un errore diretto. Gli schema in stile configurazione devono impostare esplicitamente "additionalProperties": false, mentre gli schema API dovrebbero prima valutare il costo di compatibilità.
Devo usare uno schema ricostruito come gate rigido?
No. Uno schema ricostruito descrive ciò che l'altra parte restituisce ora, non ciò che promette di restituire, quindi appartiene al monitoraggio e agli avvisi di deriva. Ciò che va in un contratto o nella documentazione API deve essere il contratto promesso.