JSON Schema: dalle basi alla produzione
In sintesi: Le parole chiave sono molte, ma solo una dozzina vengono usate ogni giorno. Metti a posto type/properties/required/$ref, gestisci bene i messaggi di errore, e copri l'ottanta per cento dei casi reali.

In sintesi: fissa prima $schema alla 2020-12, poi estrai le strutture condivise con $defs + $ref; usa oneOf solo quando deve corrispondere esattamente un ramo, e preferisci i discriminatori o anyOf ovunque altrove.
Uno schema non è un'appendice della documentazione; è un asset che può essere eseguito, testato e generato.
1. Chiarire prima la versione
| Versione | Valore di $schema |
Stato | Note |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
Superata | Progetti legacy e alcune toolchain OpenAPI 2.0 |
| draft-07 | http://json-schema.org/draft-07/schema# |
Ampiamente usata | La base de facto di OpenAPI 3.0 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
Disponibile | Ha introdotto $defs e il vocabolario delle annotazioni |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
Attualmente raccomandata | Ha introdotto prefixItems, cambiato la semantica di items |
Usa la 2020-12 per i nuovi progetti. Nota che nella 2020-12 items vincola solo gli "elementi rimanenti" — le dichiarazioni in stile tupla sono migrate a prefixItems.
È la trappola più comune quando si migra da draft-07.
2. Uno schema minimo utilizzabile
{
"$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
}
Questo schema copre le quattro preoccupazioni più comuni: vincoli di tipo, campi obbligatori, limiti di intervallo e vincoli sugli elementi degli array.
3. Mappa delle parole chiave
3.1 Generali
| Parola chiave | Scopo | Attenzione |
|---|---|---|
type |
Il tipo; può essere un array | "type": ["string", "null"] significa nullable |
enum |
Valori enumerati | I valori devono essere univoci |
const |
Un vincolo a valore singolo | Adatto a valori fissi come un numero di versione |
$ref |
Referenzia un altro schema | Referenzia l'intero schema e non si unisce alle parole chiave sorelle sull'oggetto che referenzia |
3.2 Numeri
multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum.
Nota che in draft-04 exclusiveMaximum era un booleano (usato insieme a maximum); solo dalla draft-07 è diventato una parola chiave numerica autonoma —
il che fa fallire molti "schema copiati da documenti vecchi" sotto modalità strict.
3.3 Stringhe
minLength, maxLength, pattern (un'espressione regolare ECMA-262), e format (un'annotazione).
minLength conta code point Unicode, non unità UTF-16 e non byte. Validare con string.length di JavaScript sarà in disaccordo.
3.4 Array
| Parola chiave | Scopo |
|---|---|
prefixItems |
Il vincolo in stile tupla della 2020-12, che abbina gli elementi uno per uno per indice |
items |
Un vincolo uniforme sugli elementi rimanenti (2020-12) |
contains + minContains/maxContains |
Almeno/al massimo quanti elementi corrispondenti devono essere presenti |
uniqueItems |
Gli elementi devono essere tutti diversi (confronto profondo; evitalo su array grandi) |
minItems / maxItems |
Vincoli di lunghezza |
3.5 Oggetti
properties, required, patternProperties, additionalProperties, propertyNames,
minProperties, maxProperties, dependentRequired, dependentSchemas.
Tre equivoci ad alta frequenza:
requiredrichiede solo che la chiave esista:{"name": ""}è valido. Per rifiutare una stringa vuota devi aggiungereminLength: 1;additionalProperties: falseamplifica i problemi di compatibilità: un campo extra da un client fa fallire l'intero payload, quindi evolvere l'API forza ogni chiamante ad aggiornarsi in sincrono;propertiesnon limita le chiavi non dichiarate — per impostazione predefinita sono ammesse.
3.6 Composizione logica
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| Parola chiave | Semantica | Fallimento tipico |
|---|---|---|
allOf |
Tutti devono essere soddisfatti | Più rami impongono vincoli in conflitto sullo stesso campo |
anyOf |
Almeno uno deve essere soddisfatto | Il messaggio di errore è difficile da ricondurre a un ramo specifico |
oneOf |
Esattamente uno deve essere soddisfatto | I dati corrispondono a due rami e falliscono, anche se "sembrano ovviamente corretti" |
not |
Non deve essere soddisfatto | Raramente usato, e i messaggi di errore meno amichevoli |
oneOf è la parola chiave più propensa a produrre "dati validi che falliscono la validazione". Se i vincoli dei rami si sovrappongono (per esempio, entrambi ammettono campi extra),
un singolo record corrisponde a diversi rami e oneOf fallisce del tutto. In quel caso, passa a anyOf, o usa un campo discriminatore con const dentro i rami.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Lo stile con discriminatore è più leggibile di oneOf, e produce errori migliori.
4. Divisione e riutilizzo
{
"$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" }
}
}
$defsè la forma introdotta nella 2019-09; la draft-07 usadefinitions;- I riferimenti tra file usano
$idrelativi, ma non affidarti al recupero di schema remoti a runtime in produzione — raggruppali come file statici; - Non puoi aggiungere vincoli accanto a un riferimento (il comportamento di
$refcon parole chiave sorelle varia per versione). Quando devi comporre, usaallOf.
5. Validazione a runtime con 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)}`);
}
Punti chiave:
- Chiama
compileuna sola volta, al livello superiore del modulo; ricompilare a ogni richiesta trasforma una validazione da 10 ms in centinaia di millisecondi; strict: truefa emergere subito le parole chiave scritte male (per esempiominlengthinvece diminLength), quindi vale la pena abilitarlo;- Solo
ajv-formatsfa sì cheformat: "email"eformat: "date-time"abbiano davvero effetto; - La 2020-12 richiede il punto di ingresso
ajv/dist/2020— non usare l'export predefinito.
6. Come progettare i messaggi di errore
Ciò che il validatore ti dà è "quale parola chiave è fallita"; ciò che l'utente vuole è "quale casella di input è sbagliata". Vale la pena aggiungere uno strato di mappatura:
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})`;
});
}
Lato API, mantieni una struttura leggibile dalla macchina così il frontend può decidere come presentarla:
{
"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. Prestazioni e trappole
- Il costo della validazione cresce con la complessità dello schema e la dimensione dei dati; più rami
oneOfcausano esplosione combinatoria, che diventa molto evidente con migliaia di record; uniqueItemsesegue un confronto profondo O(n²) sull'array; per array grandi, deduplica invece nel livello di business;- Evita i pattern di backtracking catastrofico in
pattern(come(a+)+$); è il classico punto di ingresso per ReDoS; - Quando
additionalProperties: falseè combinato conallOf, i campi dichiarati in un sotto-schema vengono giudicati "extra" dal genitore, quindi devi unirli in un unico schema o usareunevaluatedProperties.
8. Lista di controllo
- [ ] Fissa la 2020-12 e dichiara
$schemaall'inizio del file; - [ ] Estrai le strutture condivise con
$defs+$refinvece di copia-incollare; - [ ] Preferisci i discriminatori a
oneOf; - [ ] Riutilizza il risultato di
compiledel validatore e abilita la modalità strict; - [ ] Documenta chiaramente se
formatè imposto; - [ ] Le risposte di errore forniscono il percorso, la parola chiave e un messaggio rivolto all'utente;
- [ ] Metti lo schema in CI: esegui i casi della JSON Schema Test Suite come test di regressione.
FAQ
Come dovrei scegliere un draft di JSON Schema?
Usa la 2020-12 per i nuovi progetti e dichiara $schema all'inizio del file. Se lo schema va in OpenAPI 3.1, deve anch'esso essere 2020-12, perché la 3.1 incorpora direttamente quella versione; mantieni draft-07 solo per i progetti ancora legati a toolchain più vecchie. I due si comportano diversamente per items e per le parole chiave sorelle di $ref, quindi non mescolarli in un unico insieme di schema.
Perché additionalProperties: false amplifica i problemi di compatibilità?
Tratta ogni chiave non dichiarata come un errore, quindi un campo extra da un client fa fallire l'intero payload. Ogni chiamante deve allora aggiornarsi in sincrono ogni volta che l'API evolve, o i client vecchi vengono rifiutati. Gli schema in stile configurazione dovrebbero chiudere esplicitamente i campi extra, mentre le API pubbliche devono valutare il costo di compatibilità.
Devo usare oneOf o anyOf?
Usa oneOf solo quando i dati devono corrispondere esattamente a un ramo. Se i vincoli dei rami possono valere contemporaneamente, un record corrisponde a diversi rami e oneOf fallisce del tutto; passa a anyOf, o aggiungi un discriminatore const dentro ogni ramo.