JSON Schema: de lo básico a producción
En resumen: Hay muchas palabras clave, pero solo una docena se usan a diario. Domina type/properties/required/$ref, gestiona bien los mensajes de error y cubrirás el ochenta por ciento de los casos reales.

En resumen: fija primero $schema a 2020-12 y luego extrae las estructuras compartidas con $defs + $ref; usa oneOf solo cuando deba coincidir exactamente una rama, y prefiere discriminadores o anyOf en todos los demás casos.
Un esquema no es un apéndice de la documentación; es un activo que se puede ejecutar, probar y generar.
1. Aclara primero la versión
| Versión | Valor de $schema |
Estado | Notas |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
Obsoleta | Proyectos heredados y parte de las herramientas de OpenAPI 2.0 |
| draft-07 | http://json-schema.org/draft-07/schema# |
Muy usada | La base de facto de OpenAPI 3.0 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
Disponible | Introdujo $defs y el vocabulario de anotación |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
Recomendada actualmente | Introdujo prefixItems y cambió la semántica de items |
Usa 2020-12 para proyectos nuevos. Ten en cuenta que en 2020-12 items solo restringe los "elementos restantes" — las declaraciones de estilo tupla se trasladaron a prefixItems.
Esa es la trampa más habitual al migrar desde draft-07.
2. Un esquema mínimo usable
{
"$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
}
Este esquema cubre las cuatro preocupaciones más comunes: restricciones de tipo, campos obligatorios, límites de rango y restricciones de elementos de array.
3. Mapa de palabras clave
3.1 Generales
| Palabra clave | Propósito | Ojo con |
|---|---|---|
type |
El tipo; puede ser un array | "type": ["string", "null"] significa anulable |
enum |
Valores enumerados | Los valores deben ser únicos |
const |
Una restricción de valor único | Útil para valores fijos como un número de versión |
$ref |
Referencia a otro esquema | Referencia el esquema completo y no se fusiona con las palabras clave hermanas del objeto que referencia |
3.2 Números
multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum.
Fíjate en que en draft-04 exclusiveMaximum era un booleano (usado junto con maximum); solo a partir de draft-07 se convirtió en una palabra clave numérica independiente —
lo que hace que muchos "esquemas copiados de documentación antigua" fallen en modo estricto.
3.3 Cadenas
minLength, maxLength, pattern (una expresión regular ECMA-262) y format (una anotación).
minLength cuenta puntos de código Unicode, no unidades UTF-16 ni bytes. Validar con string.length de JavaScript dará un resultado distinto.
3.4 Arrays
| Palabra clave | Propósito |
|---|---|
prefixItems |
La restricción de estilo tupla de 2020-12, que hace coincidir los elementos uno a uno por índice |
items |
Una restricción uniforme sobre los elementos restantes (2020-12) |
contains + minContains/maxContains |
Cuántos elementos coincidentes debe haber como mínimo/como máximo |
uniqueItems |
Los elementos deben ser todos distintos (comparación profunda; evitar en arrays grandes) |
minItems / maxItems |
Restricciones de longitud |
3.5 Objetos
properties, required, patternProperties, additionalProperties, propertyNames,
minProperties, maxProperties, dependentRequired, dependentSchemas.
Tres ideas equivocadas de alta frecuencia:
requiredsolo exige que la clave exista:{"name": ""}es válido. Para rechazar una cadena vacía debes añadirminLength: 1;additionalProperties: falseamplía los problemas de compatibilidad: un campo extra de un cliente hace fallar todo el payload, así que evolucionar la API obliga a todos los consumidores a actualizarse al mismo tiempo;propertiesno restringe las claves no declaradas — por defecto se permiten.
3.6 Composición lógica
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| Palabra clave | Semántica | Fallo típico |
|---|---|---|
allOf |
Deben cumplirse todas | Varias ramas imponen restricciones contradictorias sobre el mismo campo |
anyOf |
Debe cumplirse al menos una | El mensaje de error es difícil de rastrear hasta una rama concreta |
oneOf |
Debe cumplirse exactamente una | Los datos coinciden con dos ramas y falla, aunque "parezca obviamente correcto" |
not |
No debe cumplirse | Se usa poco, y tiene los mensajes de error menos amigables |
oneOf es la palabra clave con más probabilidad de producir "datos válidos que fallan la validación". Si las restricciones de las ramas se solapan (por ejemplo, ambas permiten campos extra),
un mismo registro coincide con varias ramas y oneOf falla sin más. En ese caso, cambia a anyOf, o usa un campo discriminador con const dentro de las ramas.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
El estilo con discriminador es más legible que oneOf y produce mejores errores.
4. División y reutilización
{
"$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" }
}
}
$defses la forma introducida en 2019-09; draft-07 usadefinitions;- Las referencias entre archivos usan
$idrelativos, pero no dependas de obtener esquemas remotos en tiempo de ejecución en producción — empaquétalos como archivos estáticos; - No puedes añadir restricciones junto a una referencia (el comportamiento de
$refcon palabras clave hermanas varía según la versión). Cuando necesites composición, usaallOf.
5. Validación en tiempo de ejecución 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)}`);
}
Puntos clave:
- Llama a
compileuna sola vez, en el nivel superior del módulo; recompilar en cada petición convierte una validación de 10 ms en cientos de milisegundos; strict: truesaca a la luz pronto las palabras clave mal escritas (por ejemplominlengthen lugar deminLength), así que merece la pena activarlo;- Solo
ajv-formatshace queformat: "email"yformat: "date-time"surtan efecto de verdad; - 2020-12 requiere el punto de entrada
ajv/dist/2020— no uses la exportación por defecto.
6. Cómo diseñar los mensajes de error
Lo que el validador te da es "qué palabra clave ha fallado"; lo que el usuario quiere es "qué casilla está mal". Merece la pena añadir una capa de mapeo:
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})`;
});
}
En el lado de la API, mantén una estructura legible por máquina para que el frontend pueda decidir cómo 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. Rendimiento y trampas
- El coste de validación crece con la complejidad del esquema y el tamaño de los datos; varias ramas
oneOfprovocan una explosión combinatoria, que se nota mucho con miles de registros; uniqueItemsrealiza una comparación profunda O(n²) sobre el array; para arrays grandes, deduplica en la capa de negocio;- Evita patrones de retroceso catastrófico en
pattern(como(a+)+$); este es el punto de entrada clásico para ReDoS; - Cuando
additionalProperties: falsese combina conallOf, los campos declarados en un subesquema se consideran "adicionales" por el padre, así que necesitas fusionarlos en un único esquema o usarunevaluatedProperties.
8. Lista de comprobación
- [ ] Fija 2020-12 y declara
$schemaen la parte superior del archivo; - [ ] Extrae las estructuras compartidas con
$defs+$refen lugar de copiar y pegar; - [ ] Prefiere discriminadores a
oneOf; - [ ] Reutiliza el resultado de
compiledel validador y activa el modo estricto; - [ ] Documenta con claridad si
formatse aplica; - [ ] Las respuestas de error proporcionan la ruta, la palabra clave y un mensaje para el usuario;
- [ ] Pon el esquema en CI: ejecuta los casos de JSON Schema Test Suite como pruebas de regresión.
Preguntas frecuentes
¿Cómo debería elegir un draft de JSON Schema?
Usa 2020-12 para proyectos nuevos y declara $schema en la parte superior del archivo. Si el esquema va a OpenAPI 3.1, también debe ser 2020-12, porque 3.1 incorpora directamente esa versión; mantén draft-07 solo para proyectos todavía atados a cadenas de herramientas antiguas. Ambos se comportan de forma distinta con items y con las palabras clave hermanas de $ref, así que no los mezcles en un mismo conjunto de esquemas.
¿Por qué additionalProperties: false amplía los problemas de compatibilidad?
Trata cada clave no declarada como un error, así que un campo extra de un cliente hace fallar todo el payload. Entonces todos los consumidores deben actualizarse al mismo tiempo cada vez que la API evoluciona, o los clientes antiguos serán rechazados. Los esquemas de configuración deberían cerrar los campos extra explícitamente, mientras que las API públicas necesitan sopesar el coste de compatibilidad.
¿Debería usar oneOf o anyOf?
Usa oneOf solo cuando los datos deban coincidir exactamente con una rama. Si las restricciones de las ramas pueden cumplirse a la vez, un mismo registro coincide con varias ramas y oneOf falla sin más; cambia a anyOf, o añade un discriminador const dentro de cada rama.