JSON Toolkit

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.

Intermedio~11 min de lecturaPublicado el 2026-10-31Actualizado el 2026-10-31
Diagrama de reutilización y composición de JSON Schema con $ref, $defs y allOf/oneOf

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 $defs no tienen semántica. address, Address y addr son 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 de definitions como palabra clave desconocida.
  • $defs puede contener cualquier esquema, incluido un fragmento allOf o un subesquema independiente con su propio $id.
  • No trates $defs como 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:

  1. Una entrada properties dentro de if no falla cuando la clave está ausente. Emparéjala con required para expresar "kind existe y es igual a image".
  2. if en sí nunca produce un error de validación. Fallarlo simplemente redirige a else, así que no puede bloquear datos malos por sí solo.
  3. Un then o else solitario es inerte. Solo se aplican junto a un if.

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 en oneOf. $ref no 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.