Reutilizar JSON Schema: $ref, $defs y composición con allOf/oneOf
En resumen: $ref resuelve la reutilización, allOf y oneOf resuelven la composición, y apilar ambos es donde se rompe. Cubre $id, $defs, condicionales, recursión, empaquetado y depuración.

En resumen: extrae primero las estructuras compartidas a $defs con $ref y luego recurre a allOf/oneOf; cuando las ramas puedan coincidir a la vez, usa anyOf o una etiqueta const, y reserva oneOf para los casos que realmente exijan exactamente una coincidencia.
Copiar y pegar ahorra diez minutos y te cuesta una bifurcación silenciosa tres meses después, cuando ya nadie recuerda qué copia es la correcta.
1. Tres niveles de reutilización — elige uno antes de separar archivos
El primer instinto, cuando los campos empiezan a multiplicarse, es meter todas las restricciones en un único archivo. Es la forma más rápida de entregar, pero pasadas unas cuantas docenas de líneas cada cambio de campo hay que replicarlo en tres sitios. Olvida uno y obtienes "pasa en local, devuelve 500 en producción". Reutilizar significa escribir un hecho una vez y referenciarlo en todos los demás sitios.
JSON Schema ofrece tres niveles, cada uno con un alcance distinto:
| Nivel | Soporte | Alcance | Uso típico |
|---|---|---|---|
| Subestructura | $defs + $ref |
Un único archivo de esquema | Dirección, dinero, paginación, formas de error |
| Archivo | Esquema independiente + $id |
Varios archivos en un servicio | Entidades de usuario, pedido, producto |
| Biblioteca de contratos | Repositorio de esquemas + artefacto empaquetado | Varios servicios, varios lenguajes | Componentes de OpenAPI, contratos publicados |
Para la mayoría de proyectos basta el primer nivel. Separar en archivos solo compensa cuando dos servicios comparten de verdad el mismo contrato, o cuando el esquema es un artefacto publicado que consume otro lenguaje. Separar demasiado pronto hace que la resolución de $id y las rutas relativas sean más difíciles de depurar, y ese coste normalmente supera lo ahorrado al evitar la duplicación.
Una regla práctica sencilla: si una estructura aparece en dos sitios, una copia es tolerable; cuando aparece en un tercero, promuévela a $defs; solo cuando otro lenguaje u otro repositorio debe leerla merece un archivo independiente con su propio $id.
2. $id establece la identidad, $ref realiza el salto
$id no es un comentario. Declara el URI base del esquema. Un valor $ref es una referencia URI: se resuelve contra el URI base actual hasta convertirse en un URI absoluto, y luego el fragmento después de # localiza un nodo. Si el URI base es incorrecto, todas las referencias relativas apuntan a otro sitio, y aun así el error normalmente solo dice "can't resolve reference" sin nombrar el host equivocado.
{
"$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 mientras que billTo usa un ancla; ambos aterrizan en el mismo nodo. La tabla siguiente asocia cada forma con su resolución:
| Forma | Apunta a | Draft | Notas |
|---|---|---|---|
# |
Raíz del esquema actual | Todos | Habitual para recursión |
#/$defs/address |
Nodo bajo $defs |
2019-09 y posteriores | La más clara; preferida |
#address |
Ancla con nombre | $anchor en 2020-12 |
Draft-07 escribía $id: "#address" |
common.json |
Archivo relativo al URI base | Todos | Debe registrarse o empaquetarse |
https://example.com/x.json |
URI absoluto | Todos | Evita obtenerlo en tiempo de ejecución en producción |
Una trampa sutil: si un subesquema dentro de $defs declara su propio $id, ese pasa a ser el nuevo URI base para todo lo que hay debajo. Pega un esquema copiado de otro archivo y el $id sobrante redirigirá sus referencias relativas al host original — y el validador solo se dará cuenta cuando intente resolverlas de verdad.
3. $defs es un contenedor, no un punto de validación
$defs es un mapa normal. Nada de lo que contiene surte efecto hasta que un $ref apunta allí. Una lista required situada en la parte superior de $defs sin ninguna referencia entrante no valida absolutamente nada, por muy estricta que parezca. Eso es la especificación funcionando como debe, no un fallo del validador.
{
"$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" }
}
}
Merece la pena fijar algunas convenciones en un equipo:
- Las claves dentro de
$defsno tienen semántica.address,Addressyaddrson todas legales, así que acordad un estilo. - Antes de 2019-09 la palabra clave era
definitions. A ambas se puede apuntar con$ref, pero el modo estricto puede informar dedefinitionscomo palabra clave desconocida. $defspuede contener cualquier esquema, incluido un fragmentoallOfo un subesquema independiente con su propio$id.- No trates
$defscomo herencia de plantillas. Solo almacena definiciones; el significado sigue viniendo del contexto que referencia.
4. allOf, anyOf y oneOf no son intercambiables
Los tres implican varias ramas, pero sus reglas de aceptación difieren. Cuando los datos coinciden con dos ramas, allOf y anyOf pasan mientras que oneOf falla — la razón más común por la que la gente concluye "el esquema está roto".
| Palabra clave | Coincidencias mínimas | Coincidencias máximas | Datos que coinciden con dos ramas | Calidad del error | Uso típico |
|---|---|---|---|---|---|
allOf |
Todas | Ilimitadas | Pasa | Buena, una restricción a la vez | Combinar fragmentos de restricciones |
anyOf |
1 | Ilimitadas | Pasa | Mala, las ramas se acumulan | "Email o teléfono, cualquiera de los dos" |
oneOf |
1 | 1 | Falla | Mala | Uniones discriminadas |
not |
0 | 0 | Falla | La peor | Excluir unos pocos valores; se usa poco |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf no fusiona definiciones de objetos; solo exige que se cumpla cada rama. Cada rama se evalúa de forma independiente, así que un additionalProperties: false dentro de una rama solo conoce las claves que esa rama declaró y marcará como extra los campos declarados por otra rama. En 2020-12 la solución es poner la política de propiedades desconocidas en el nivel superior y usar unevaluatedProperties: false, que se evalúa una sola vez después de todas las ramas.
Usa anyOf para "varias formas legales", no para "varias formas mutuamente exclusivas". Si dos ramas pueden coincidir a la vez, oneOf produce el famoso y poco útil must match exactly one schema in oneOf.
5. if/then/else y discriminador: haz que las condiciones sean decidibles
Las restricciones condicionales tienden a producir mejores errores que oneOf porque el camino de decisión es explícito: si if pasa, se aplica then; de lo contrario se aplica 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"] }
}
Tres detalles determinan si se comporta como se pretende:
- Una entrada
propertiesdentro deifno falla cuando la clave está ausente. Emparéjala conrequiredpara expresar "kind existe y es igual a image". ifen sí nunca produce un error de validación. Fallarlo simplemente redirige aelse, así que no puede bloquear datos malos por sí solo.- Un
thenoelsesolitario es inerte. Solo se aplican junto a unif.
A medida que crecen las ramas de unión, la cadena condicional se convierte en un anidamiento imposible de mantener. Un patrón más sólido es fijar un campo de etiqueta dentro de cada rama para que las ramas sean mutuamente exclusivas por construcción:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator es una extensión de OpenAPI 3.x, no una palabra clave de JSON Schema. Ajv lo admite cuando se activa la opción discriminator: true, pero su mapeo vive en mapping y no es portable entre cadenas de herramientas. Si el esquema debe funcionar en varios validadores, prefiere el patrón de etiqueta const, que no depende de ninguna extensión.
6. Referencias recursivas: árboles, listas enlazadas y archivos que se citan entre sí
Las estructuras recursivas no necesitan sintaxis especial — basta con un $ref que apunte a sí mismo. Los validadores no expanden las referencias de forma infinita en tiempo de compilación; recurren según la profundidad real de los datos, así que unas pocas docenas de líneas describen un árbol de cualquier altura.
{
"$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"
}
Merece la pena resolver de antemano tres límites:
- Profundidad de los datos: el esquema no impone ningún límite, pero la validación y la serialización consumen pila proporcional a la profundidad. Las API públicas deberían añadir un límite de negocio, digamos ocho niveles, o un payload maliciosamente profundo puede desbordar la pila.
- Referencias mutuas: A referencia a B y B referencia a A es legal, pero ambos esquemas deben registrarse primero; de lo contrario el orden de resolución decide el resultado.
- Ramas anulables: si un campo recursivo puede ser
null, escribe"type": ["object", "null"]o envuélvelo enoneOf.$refno hereda la anulabilidad.
7. Referencias entre archivos y empaquetado: nunca obtengas esquemas en producción
Las referencias entre archivos a menudo "funcionan sin más" en desarrollo porque el validador obtiene el destino por $id a través de la red. Cambia la política de red en el momento del despliegue y toda la cadena de validación se rompe. La regla de producción es sencilla: todo esquema referenciado debe estar registrado en el proceso o empaquetado de antemano.
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);
Si order.json tiene $id: "https://bbdrug.com/schemas/order.json", su "$ref": "address.json" se resuelve a https://bbdrug.com/schemas/address.json, que coincide con la clave registrada. Un solo carácter distinto produce can't resolve reference.
Las tres estrategias prácticas se comparan así:
| Enfoque | Cómo | Fortaleza | Coste |
|---|---|---|---|
| Registrar en tiempo de ejecución | addSchema por archivo |
Sin paso de compilación, los archivos siguen independientes | El orden de registro y las claves son manuales |
| Empaquetar en tiempo de compilación | Fusionar con un parser de referencias en un único archivo | Cero dependencias en tiempo de ejecución | Artefacto más grande, diffs ruidosos |
| Carga asíncrona | loadSchema + compileAsync |
Maneja esquemas que cambian con frecuencia | Preocupaciones de red y caché; evitar en rutas críticas |
Para proyectos de frontend, empaquetar en tiempo de compilación es más seguro. En el servidor, el registro es suficiente, siempre que una prueba demuestre que todas las referencias se resuelven. En cualquier caso, ejecuta compile en CI para que los errores de referencia salgan antes de la fusión y no en la primera petición de producción.
8. Orden de depuración: resolver, compilar y luego inspeccionar las ramas
Casi todos los problemas de $ref se pueden localizar en tres etapas, y el orden importa:
| Síntoma | Causa probable | Arreglo |
|---|---|---|
can't resolve reference ... from id ... |
Error tipográfico en $id, base relativa incorrecta, destino no registrado |
Imprime el $id y el URI absoluto resuelto, y compáralo con las claves registradas |
schema with key or id already exists |
El mismo $id registrado dos veces, o compile llamado repetidamente |
Mueve el registro a la inicialización del módulo y ejecútalo una vez |
strict mode: unknown keyword: "definitions" |
Mezcla de sintaxis draft-07 | Renombra a $defs; no desactives el modo estricto |
Los datos coinciden con dos ramas oneOf |
Restricciones de rama solapadas, sin campo de etiqueta | Añade const a cada rama, o cambia a anyOf |
Las restricciones dentro de $defs no tienen efecto |
Nada las referencia | Comprueba la ruta de referencia; $defs no valida nada por sí solo |
Al depurar, lee schemaPath antes que message: nombra la rama y la palabra clave que fallaron y es mucho más fiable que el texto en inglés. Cachea el validador compilado en el ámbito del módulo en lugar de recompilar en cada petición. Para cada cambio de esquema, cubre tanto un caso que pasa como uno que falla — probar solo el camino feliz no prueba nada. Para la selección del validador y el diseño de mensajes de error, consulta JSON Schema: de lo básico a producción; para conectar un esquema a la generación de tipos, consulta Sincronización bidireccional entre esquema y tipos de TypeScript.
9. Preguntas frecuentes
¿Puede $ref convivir con type o description?
2020-12 permite $ref junto a palabras clave hermanas, y esas hermanas se evalúan con normalidad. En draft-07 las hermanas se ignoran y solo se aplica $ref. Esta es la mayor diferencia de comportamiento cuando el mismo esquema se usa entre drafts, así que confirma qué draft admite tu validador antes de migrar.
¿Debería usar $defs o definitions?
Usa $defs para trabajo nuevo; llegó con 2019-09. definitions no es una palabra clave en 2020-12. Un pointer como $ref: "#/definitions/foo" todavía puede resolverse porque es JSON Pointer simple, pero el modo estricto informará de una palabra clave desconocida. Mezclar ambos estilos deja una mina que solo explota en CI.
¿Por qué oneOf rechaza datos que parecen válidos?
Porque los datos satisfacen dos ramas a la vez. oneOf exige exactamente una, y una rama que solo restringe una parte del objeto se satisface incluso cuando los campos de la otra rama están ausentes. Añade una etiqueta const a cada rama para hacerlas mutuamente exclusivas.
¿Las referencias recursivas entrarán en bucle infinito?
No. Los validadores no expanden las referencias en tiempo de compilación; recurren sobre los datos reales. Los riesgos reales son datos lo bastante profundos como para desbordar la pila y un orden de resolución incorrecto entre archivos que se referencian mutuamente. Un límite de profundidad a nivel de negocio en los campos recursivos es más eficaz que cualquier truco de esquema.
¿Puede producción depender de $ref remotos?
No. Las referencias remotas atan la disponibilidad de la validación a la red: los cambios de dominio, los certificados caducados y el aislamiento de red interno rompen la cadena. Empaqueta los esquemas referenciados en el artefacto o regístralos al arrancar si quieres un comportamiento predecible.
¿Cómo decido entre $defs y archivos separados?
Una estructura que aparece en dos sitios se puede copiar una vez; cuando aparece en un tercero, promuévela a $defs. Solo cuando otro lenguaje u otro repositorio debe leerla merece un archivo independiente con su propio $id.
¿Por qué las restricciones dentro de $defs no tienen efecto?
$defs es solo un contenedor, y los nodos a los que ningún $ref apunta nunca participan en la validación. Una lista required en la parte superior de $defs sin referencia entrante sigue dejando pasar los datos — eso es la especificación, no un fallo del validador.
¿Debería poner additionalProperties: false dentro de las ramas de allOf?
No. Cada rama se evalúa de forma independiente, así que una rama trata como extra los campos declarados por otra. El enfoque de 2020-12 mueve la política de campos desconocidos al nivel superior y usa unevaluatedProperties: false, evaluado una vez después de todas las ramas.