JSON Toolkit

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.

Intermedio~11 min di letturaPubblicato il 2026-10-31Aggiornato il 2026-10-31
Diagramma del riutilizzo e della composizione di JSON Schema con $ref, $defs e allOf/oneOf

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 $defs non portano semantica. address, Address e addr sono 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ò segnalare definitions come parola chiave sconosciuta.
  • $defs può contenere qualsiasi schema, incluso un frammento allOf o un sotto-schema indipendente con il proprio $id.
  • Non trattare $defs come 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:

  1. Una voce properties dentro if non fallisce quando la chiave manca. Abbinahi required per esprimere "kind esiste e vale image".
  2. if stesso non produce mai un errore di validazione. Fallirlo semplicemente instrada verso else, quindi non può bloccare dati cattivi da solo.
  3. Un then o else da solo è inerte. Si applicano solo accanto a un if.

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 in oneOf. $ref non 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.