Riutilizzare JSON Schema: $ref, $defs e composizione allOf/oneOf
In sintesi: $ref risolve il riutilizzo, allOf e oneOf risolvono la composizione, ed è impilandoli entrambi che le cose si rompono. Copre $id, $defs, condizionali, ricorsione, bundling e debugging.

In sintesi: estrai prima le strutture condivise in $defs con $ref, poi ricorri a allOf/oneOf; quando i rami possono corrispondere contemporaneamente, usa anyOf o un tag const, e riserva oneOf ai casi che richiedono davvero esattamente una corrispondenza.
Il copia-incolla risparmia dieci minuti e ti costa un fork silenzioso tre mesi dopo, quando nessuno ricorda quale copia sia corretta.
1. Tre strati di riutilizzo — scegline uno prima di dividere i file
Il primo istinto, quando i campi iniziano a moltiplicarsi, è ficcare ogni vincolo in un unico file. È il modo più veloce per spedire, ma oltre un paio di centinaia di righe ogni modifica di un campo deve essere rispecchiata in tre posti. Ne perdi uno e ottieni "passa in locale, restituisce 500 in produzione". Riutilizzo significa scrivere un fatto una volta e referenziarlo ovunque altrove.
JSON Schema offre tre strati, ciascuno con uno scopo diverso:
| Strato | Portatore | Scopo | Uso tipico |
|---|---|---|---|
| Sotto-struttura | $defs + $ref |
Un file di schema | Indirizzo, denaro, paginazione, forme di errore |
| File | Schema autonomo + $id |
Diversi file in un servizio | Entità user, order, product |
| Libreria di contratti | Repository di schema + artefatto raggruppato | Diversi servizi, diversi linguaggi | Componenti OpenAPI, contratti pubblicati |
Per la maggior parte dei progetti il primo strato basta. Dividere in file dà frutti solo quando due servizi condividono davvero lo stesso contratto, o quando lo schema è un artefatto pubblicato consumato da un altro linguaggio. Dividere troppo presto rende la risoluzione di $id e i percorsi relativi più difficili da debuggare, e quel costo di solito supera ciò che hai risparmiato evitando la duplicazione.
Una semplice regola pratica: se una struttura compare in due posti, una copia è tollerabile; quando compare in un terzo, promuovila a $defs; solo quando un altro linguaggio o repository deve leggerla merita un file autonomo con il proprio $id.
2. $id stabilisce l'identità, $ref esegue il salto
$id non è un commento. Dichiara l'URI base dello schema. Un valore $ref è un riferimento URI: viene risolto contro l'URI base corrente in un URI assoluto, e poi il frammento dopo # localizza un nodo. Se l'URI base è sbagliato, ogni riferimento relativo punta altrove, eppure l'errore di solito dice solo "can't resolve reference" senza nominare l'host sbagliato.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo usa un JSON Pointer mentre billTo usa un anchor; entrambi atterrano sullo stesso nodo. La tabella sotto mappa ogni forma alla sua risoluzione:
| Forma | Punta a | Draft | Note |
|---|---|---|---|
# |
Radice dello schema corrente | Tutti | Comune per la ricorsione |
#/$defs/address |
Nodo sotto $defs |
2019-09 e successivi | Il più chiaro; preferito |
#address |
Anchor con nome | $anchor nella 2020-12 |
La draft-07 scriveva $id: "#address" |
common.json |
File relativo all'URI base | Tutti | Deve essere registrato o raggruppato |
https://example.com/x.json |
URI assoluto | Tutti | Evita il recupero a runtime in produzione |
Una trappola sottile: se un sotto-schema dentro $defs dichiara il proprio $id, quello diventa il nuovo URI base per tutto ciò che sta sotto. Incolla uno schema copiato da un altro file e l'$id superfluo reindirizzerà i suoi riferimenti relativi all'host originale — e il validatore se ne accorge solo quando prova davvero a risolverli.
3. $defs è un contenitore, non un sito di validazione
$defs è una mappa ordinaria. Nulla dentro di essa ha effetto finché un $ref non punta lì. Una lista required in cima a $defs senza un riferimento in entrata non valida esattamente nulla, per quanto severa sembri. È la specifica che funziona come previsto, non un bug del validatore.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "A two-decimal money string that avoids floating-point drift"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
Alcune convenzioni valgono la pena di essere fissate in una squadra:
- Le chiavi dentro
$defsnon portano semantica.address,Addresseaddrsono tutte legali, quindi concordate uno stile. - Prima della 2019-09 la parola chiave era
definitions. Entrambe possono essere target di$ref, ma la modalità strict può segnalaredefinitionscome parola chiave sconosciuta. $defspuò contenere qualsiasi schema, incluso un frammentoallOfo un sotto-schema indipendente con il proprio$id.- Non trattare
$defscome ereditarietà dei template. Memorizza solo definizioni; il significato viene comunque dal contesto che referenzia.
4. allOf, anyOf e oneOf non sono intercambiabili
Tutti e tre coinvolgono più rami, ma le loro regole di accettazione differiscono. Quando i dati corrispondono a due rami, allOf e anyOf passano mentre oneOf fallisce — la singola ragione più comune per cui la gente conclude "lo schema è rotto".
| Parola chiave | Corrispondenze minime | Corrispondenze massime | Dati che corrispondono a due rami | Qualità degli errori | Uso tipico |
|---|---|---|---|---|---|
allOf |
Tutti | Illimitate | Passa | Buona, un vincolo alla volta | Combinare frammenti di vincoli |
anyOf |
1 | Illimitate | Passa | Scarsa, i rami si accumulano | "Email o telefono, uno dei due" |
oneOf |
1 | 1 | Fallisce | Scarsa | Unioni discriminate |
not |
0 | 0 | Fallisce | Peggiore | Escludere qualche valore; raramente usato |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf non unisce le definizioni di oggetti; richiede solo che ogni ramo valga. Ogni ramo viene valutato indipendentemente, quindi un additionalProperties: false dentro un ramo conosce solo le chiavi che quel ramo ha dichiarato e segnerà i campi dichiarati da un altro ramo come extra. Nella 2020-12 la correzione è mettere la politica sulle proprietà sconosciute al livello superiore e usare unevaluatedProperties: false, che viene valutato una volta dopo tutti i rami.
Usa anyOf per "diverse forme legali", non per "diverse forme mutuamente esclusive". Se due rami possono corrispondere contemporaneamente, oneOf produce il famigerato poco utile must match exactly one schema in oneOf.
5. if/then/else e discriminator: rendere decidibili le condizioni
I vincoli condizionali tendono a produrre errori migliori di oneOf perché il percorso di decisione è esplicito: if passa, then si applica; altrimenti si applica else.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Tre dettagli determinano se si comporta come previsto:
- Una voce
propertiesdentroifnon fallisce quando la chiave manca. Abbinahirequiredper esprimere "kind esiste e vale image". ifstesso non produce mai un errore di validazione. Fallirlo semplicemente instrada versoelse, quindi non può bloccare dati cattivi da solo.- Un
thenoelseda solo è inerte. Si applicano solo accanto a unif.
Man mano che i rami di unione crescono, la catena condizionale diventa un annidamento inmantenibile. Un pattern più solido è fissare un campo tag dentro ogni ramo così che i rami siano mutuamente esclusivi per costruzione:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator è un'estensione di OpenAPI 3.x, non una parola chiave di JSON Schema. Ajv lo supporta quando l'opzione discriminator: true è abilitata, ma la sua mappatura vive in mapping e non è portabile tra toolchain. Se lo schema deve funzionare in diversi validatori, preferisci il pattern del tag const, che non dipende da alcuna estensione.
6. Riferimenti ricorsivi: alberi, liste collegate e file che si citano a vicenda
Le strutture ricorsive non necessitano sintassi speciale — un $ref a se stesso basta. I validatori non espandono i riferimenti all'infinito in fase di compilazione; ricorrono in base alla profondità effettiva dei dati, quindi poche dozzine di righe descrivono un albero di qualsiasi altezza.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
Tre confini valgono la pena di essere fissati in anticipo:
- Profondità dei dati: lo schema non impone alcun limite, ma validazione e serializzazione consumano entrambe uno stack proporzionale alla profondità. Le API pubbliche dovrebbero aggiungere un limite di business, diciamo otto livelli, o un payload maliziosamente profondo può far overflow dello stack.
- Riferimenti reciproci: A referenzia B e B referenzia A è legale, ma entrambi gli schema devono essere registrati prima; altrimenti l'ordine di risoluzione decide l'esito.
- Rami nullable: se un campo ricorsivo può essere
null, scrivi"type": ["object", "null"]o avvolgilo inoneOf.$refnon eredita la nullability.
7. Riferimenti tra file e bundling: non recuperare mai schema in produzione
I riferimenti tra file spesso "funzionano e basta" in sviluppo perché il validatore recupera il target tramite $id sulla rete. Cambia la politica di rete al momento del deploy e l'intera catena di validazione si rompe. La regola di produzione è semplice: ogni schema referenziato deve essere registrato in-process o raggruppato in anticipo.
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Compile the entry schema once, at module scope
export const validateOrder = ajv.compile(orderSchema);
Se order.json ha $id: "https://bbdrug.com/schemas/order.json", il suo "$ref": "address.json" si risolve in https://bbdrug.com/schemas/address.json, che corrisponde alla chiave registrata. Un singolo carattere diverso produce can't resolve reference.
I tre approcci pratici si confrontano così:
| Approccio | Come | Punto di forza | Costo |
|---|---|---|---|
| Registrazione a runtime | addSchema per file |
Nessun passo di build, i file restano indipendenti | Ordine e chiavi di registrazione manuali |
| Bundling al momento della build | Unisci con un parser di ref in un file | Zero dipendenze a runtime | Artefatto più grande, diff rumorosi |
| Caricamento asincrono | loadSchema + compileAsync |
Gestisce schema che cambiano spesso | Preoccupazioni di rete e cache; evitalo sui percorsi critici |
Per i progetti frontend, il bundling al momento della build è più sicuro. Sul server, la registrazione basta, purché un test provi che ogni riferimento si risolva. In entrambi i casi, esegui compile in CI così gli errori di riferimento emergono prima del merge invece che alla prima richiesta in produzione.
8. Ordine di debug: risolvi, compila, poi ispeziona i rami
Quasi ogni problema $ref può essere localizzato in tre fasi, e l'ordine conta:
| Sintomo | Causa probabile | Correzione |
|---|---|---|
can't resolve reference ... from id ... |
Errore di battitura nell'$id, base relativa sbagliata, target non registrato |
Stampa l'$id e l'URI assoluto risolto, poi confronta con le chiavi registrate |
schema with key or id already exists |
Lo stesso $id registrato due volte, o compile chiamato ripetutamente |
Sposta la registrazione all'init del modulo ed eseguila una volta |
strict mode: unknown keyword: "definitions" |
Sintassi draft-07 mescolata | Rinomina in $defs; non disabilitare la modalità strict |
I dati corrispondono a due rami oneOf |
Vincoli dei rami sovrapposti, nessun campo tag | Aggiungi const a ogni ramo, o passa a anyOf |
I vincoli dentro $defs non hanno effetto |
Nulla li referenzia | Controlla il percorso di riferimento; $defs da solo non valida nulla |
Durante il debug, leggi schemaPath prima di message: nomina il ramo e la parola chiave che hanno fallito ed è molto più affidabile della prosa inglese. Metti in cache il validatore compilato a livello di modulo invece di ricompilare per richiesta. Per ogni modifica dello schema, copri sia un caso che passa sia uno che fallisce — testare solo il percorso felice non testa nulla. Per la selezione del validatore e il design dei messaggi di errore, vedi JSON Schema: dalle basi alla produzione; per collegare uno schema alla generazione dei tipi, vedi Sincronizzazione bidirezionale tra Schema e tipi TypeScript.
9. Domande frequenti
$ref può vivere accanto a type o description?
La 2020-12 consente $ref accanto a parole chiave sorelle, e quelle sorelle vengono valutate normalmente. Nella draft-07 le sorelle vengono ignorate e si applica solo $ref. È la più grande differenza di comportamento quando lo stesso schema è usato attraverso draft diversi, quindi conferma quale draft supporta il tuo validatore prima di migrare.
Devo usare $defs o definitions?
Usa $defs per i nuovi lavori; è arrivato con la 2019-09. definitions non è una parola chiave nella 2020-12. Un pointer come $ref: "#/definitions/foo" può comunque risolversi perché è JSON Pointer puro, ma la modalità strict segnalerà una parola chiave sconosciuta. Mescolare i due stili lascia una mina che esplode solo in CI.
Perché oneOf rifiuta dati che sembrano validi?
Perché i dati soddisfano due rami contemporaneamente. oneOf ne richiede esattamente uno, e un ramo che vincola solo parte dell'oggetto è soddisfatto anche quando i campi dell'altro ramo sono assenti. Aggiungi un tag const a ogni ramo per renderli mutuamente esclusivi.
I riferimenti ricorsivi vanno in loop per sempre?
No. I validatori non espandono i riferimenti in fase di compilazione; ricorrono sui dati effettivi. I rischi reali sono dati abbastanza profondi da far overflow dello stack e un ordine di risoluzione sbagliato tra file che si referenziano a vicenda. Un limite di profondità a livello di business sui campi ricorsivi è più efficace di qualsiasi trucco di schema.
La produzione può affidarsi a $ref remoti?
No. I riferimenti remoti legano la disponibilità della validazione alla rete: cambi di dominio, certificati scaduti e isolamento di rete interno rompono tutti la catena. Raggruppa gli schema referenziati nell'artefatto o registrali all'avvio se vuoi un comportamento prevedibile.
Come decido tra $defs e file separati?
Una struttura che compare in due posti può essere copiata una volta; quando compare in un terzo posto, promuovila a $defs. Solo quando un altro linguaggio o un altro repository deve leggerla merita un file autonomo con il proprio $id.
Perché i vincoli dentro $defs non hanno effetto?
$defs è solo un contenitore, e i nodi a cui nessun $ref punta non partecipano mai alla validazione. Una lista required in cima a $defs senza riferimento in entrata lascia comunque passare i dati — è la specifica, non un bug del validatore.
Devo mettere additionalProperties: false dentro i rami allOf?
No. Ogni ramo viene valutato indipendentemente, quindi un ramo tratta i campi dichiarati da un altro ramo come extra. L'approccio della 2020-12 sposta la politica sui campi sconosciuti al livello superiore e usa unevaluatedProperties: false, valutato una volta dopo tutti i rami.