JSON Toolkit

Por qué el JSON Schema autogenerado sigue rechazando datos reales

En resumen: Los esquemas generados suelen bloquear datos válidos. Descubre por qué las muestras más ricas producen esquemas peores y luego el flujo de trabajo: muestreo de varios registros, compleción semántica y localización de errores.

Intermedio~14 min de lecturaPublicado el 2026-10-30Actualizado el 2026-10-30
Portada que ilustra los conflictos entre un borrador de JSON a Schema y los datos reales

En resumen: converge primero a mano el límite entre obligatorio y opcional — alimenta con 5–20 registros de formas distintas y "todo obligatorio" desactivado — luego genera el esquema y añade format, enum y additionalProperties manualmente; una única muestra con todos los campos siempre será demasiado estricta.

El generador solo puede ver el único registro que has pegado. Interpreta "este registro tiene un email" como "todos los registros deben tener un email", así que cuanto más completas sean tus muestras, más exigirá.

1. Un generador hace mapeo estructural, no comprensión del negocio

Entrega un fragmento de JSON a un generador y hará exactamente una cosa: recorrer la estructura de los valores y traducir cada nodo a palabras clave del esquema. Toda la tabla de mapeo es fija:

Valor en la muestra Tipo inferido Notas
"hello" {"type": "string"} No distingue email, fecha ni UUID
42 {"type": "number"} Un entero no se infiere como integer
3.14 {"type": "number"} Igual que arriba
true {"type": "boolean"} —
null {"type": "null"} Solo reconoce null, nunca "nullable"
[1, "a"] {"type": "array", "items": {"type": ["number", "string"]}} Los tipos de elemento se unen
{"a": 1} {"type": "object", "properties": {...}, "required": [...]} Aquí required es el quid

Todo el problema está en la última fila. required no se deriva de los tipos; se deriva de "esta clave existe en esta muestra". Cuando la muestra es un único registro, esa frase equivale a "todas las claves son obligatorias".

Y los datos que devuelven las API reales casi siempre tienen la mitad de los campos opcionales. Ahí empieza el conflicto.

2. Cinco registros de la misma forma, cinco tasas de aceptación

Empieza con medidas. Cualquiera que vea los cinco registros siguientes diría "tienen la misma forma":

[
  {
    "id": 1,
    "name": "Ada",
    "email": "ada@example.com",
    "tags": ["a"],
    "score": 10
  },
  { "id": 2, "name": "Bob", "tags": [], "score": 7 },
  {
    "id": 3,
    "name": "Cy",
    "email": "cy@example.com",
    "tags": ["a", "b"],
    "score": 8.5
  },
  { "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
  {
    "id": 5,
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["d"],
    "score": 6,
    "admin": true
  }
]

email aparece en 3 de los 5 registros, admin aparece solo una vez, y el registro 2 tiene un array tags vacío.

Ahora genera un esquema a partir de cada registro por separado y usa cada resultado para validar los cinco registros (validador: ajv 8):

Generado a partir de Número de claves required generado Acepta de 5
Registro 1 5 id, name, email, tags, score 3 registros
Registro 2 4 id, name, tags, score 5 registros
Registro 3 5 id, name, email, tags, score 3 registros
Registro 4 5 id, name, email, tags, score 1 registro
Registro 5 6 id, name, email, tags, score, admin 1 registro

Ninguno de los cinco esquemas es correcto, pero el único que acepta todos los datos proviene del registro 2, el registro al que le falta email. Gana por una razón simple: las claves que nunca aparecen nunca se escriben en required.

El registro 4 es aún más instructivo: su valor email es null, así que el generador fija {"type": "null"}, y los otros cuatro registros con emails de tipo cadena se rechazan todos.

3. Por qué "pega el registro más completo" es la peor elección

La intuición es "pega la muestra con más campos y el esquema será completo". Las mediciones muestran exactamente lo contrario — y un tercero realizó una prueba controlada más amplia.

absurdtools usó 3000 "familias de datos" aleatorias, 8 registros por familia, con cada campo opcional presente aproximadamente la mitad de las veces, y midió cuántos datos podía aceptar un esquema generado a partir de muestras con distinto número de claves:

Número de claves de la muestra Proporción de los datos de la familia aceptados
2 100,0%
3 56,8%
4 34,8%
5 23,4%
6 17,7%
7 14,9%

La conclusión no es "un ligero descenso" sino una inversión completa: la muestra mínima produjo el esquema más laxo en el 95% de las familias, mientras que la muestra más completa solo lo logró el 0,1% de las veces.

La razón está en la semántica de required, que significa "todas"; la opcionalidad solo se puede observar mediante la ausencia. Pegar un registro con todos los campos le dice al generador "no se puede omitir ninguna de estas claves"; pegar un registro con un campo ausente le entrega la evidencia de que "esta clave puede faltar".

Recuérdalo en una frase: el valor de una muestra no es su completitud, sino qué ausencias cubre.

Cuántos registros son suficientes

La misma tanda de experimentos también midió la velocidad de convergencia: acumulando registros uno a uno bajo la regla "una clave sigue siendo obligatoria solo si todos los registros hasta ahora la tienen", la mediana de registros para que required converja al conjunto realmente obligatorio es 3; 6 registros cubren aproximadamente el noventa por ciento de las familias, y 12 las cubren todas.

Así que tomar muestras de 5–10 registros en el día a día es la mejor relación entre esfuerzo y valor; forzar veinte o más aporta muy poco extra.

Pero hay una precondición: estos registros deben venir de situaciones genuinamente distintas. Cinco registros copiados de la misma plantilla dejan de aportar información nueva a partir del segundo — sus conjuntos de campos son idénticos, y required no puede estrecharse ni una sola clave.

4. La solución: alimenta con varios registros a la vez y deja hablar a las ausencias

Dado que el muestreo de un solo registro es necesariamente demasiado estricto, el enfoque correcto es dejar que el generador mire varios registros a la vez y haga converger required con la regla "obligatorio solo si todos los registros lo tienen".

El generador de este sitio acepta un array directamente y tiene un interruptor de "todo obligatorio". Desactívalo y decidirá por número de apariciones: solo las claves presentes en todos los registros se escriben en required.

Siguen siendo aquellos cinco registros — esta vez combínalos en un array e infiere en conjunto, y luego toma items como el contrato del elemento:

Registros alimentados required inferido Validación de los 5
1 registro id, name, email, tags, score 3 pasan
2 registros id, name, tags, score 4 pasan
3 registros id, name, tags, score 4 pasan
4 registros id, name, tags, score 5 pasan
5 registros id, name, tags, score 5 pasan

required converge en cuanto se añade el cuarto registro, y no cambia después. email y admin quedan correctamente en properties pero no en required. Y como el email del registro 4 es null, la inferencia de varios registros lo combina en {"type": ["string", "null"]}, así que tanto las cadenas como null pasan — algo que el muestreo de un solo registro nunca puede hacer.

Esto está en el mismo orden de magnitud que el hallazgo de absurdtools de que "required converge tras una mediana de 3 registros".

Sin embargo, la inferencia de varios registros tiene sus propios puntos ciegos: si el email de estos registros resulta ser null en todos (digamos que todos vienen de cuentas sin perfil completado), el resultado de la inferencia pasa a ser {"type": "null"}, y los emails de tipo cadena siguen rechazándose. Del mismo modo, si todos los valores de tags son arrays vacíos, el tipo de elemento no se puede inferir en absoluto.

Así que el muestreo tiene un requisito adicional estricto: cada forma de cada campo debe aparecer al menos una vez. Trae una cadena, un número, un null y un array vacío; no dejes que ninguna forma esté totalmente ausente.

En concreto:

  1. Extrae 5–20 respuestas de logs, mocks o peticiones reales, y asegúrate de incluir las filas anómalas (la que tiene campos vacíos, la que tiene null, la que tiene un array vacío);
  2. Montarlas en un array JSON y pégalo en el generador;
  3. Desactiva "todo obligatorio" y elige draft 2020-12;
  4. El resultado es type: array más un items; toma la sección items como el contrato de un único registro;
  5. Valida inmediatamente las muestras originales con este esquema — el 100% debe pasar. Si falla aunque sea una, tu muestreo no es suficiente; vuelve atrás y añade registros.

El paso 5 es la autocomprobación más barata. Si el generador no puede ni siquiera acomodar los datos que le has alimentado, tu método de muestreo es incorrecto, y no tiene sentido seguir adelante.

5. Cuatro cosas que un generador no puede rellenar — debes escribirlas a mano

Incluso después de alimentar con varios registros, lo siguiente sigue sin poder inferirse y debe añadirse manualmente:

Pieza que falta Comportamiento medido Consecuencia Cómo arreglarlo
Formato de cadena "a@b.com" produce solo {"type": "string"} Validar "not an email" pasa Añade "format": "email" y activa las comprobaciones de formato
Rangos y enumeraciones No puede ver que score está limitado a 0–100, ni que status solo tiene tres valores Números negativos, valores enormes y valores de enum basura pasan todos Añade minimum / maximum / enum
Política de campos desconocidos No se genera additionalProperties Una clave mal escrita se acepta en silencio, lo que es más peligroso que un error Añade "additionalProperties": false para archivos de configuración
Nulabilidad y semántica de colecciones vacías Una única muestra que contiene null se fija a {"type": "null"}; un array vacío produce "items": {} Se rechazan valores válidos, o los elementos del array quedan completamente sin restringir Cambia a ["string", "null"], y da elementos reales a los arrays vacíos

La tercera fila merece mención aparte: el valor por defecto de additionalProperties es "permitir". Eso significa que un archivo de configuración que escribe timeout como timeuot pasa la validación y luego no hace nada en silencio en tiempo de ejecución. Para esquemas de configuración, este es un elemento que debes desactivar explícitamente.

format también tiene una trampa: desde draft 2019-09, format pertenece al vocabulario de anotación y por defecto solo anota en lugar de aplicar. Si añadiste format: "email" y los valores ilegales siguen pasando, en ocho de cada diez casos el validador no ha activado validateFormats o el plugin de formato correspondiente — no es que el esquema esté mal.

Una nota sobre la elección de draft, ya que estamos: si el esquema va a ir a OpenAPI 3.1, debe ser draft 2020-12, porque 3.1 incorpora directamente esa versión; las cadenas de herramientas antiguas (el ecosistema Swagger 2.0 y algunas implementaciones de $ref) siguen centradas en draft-07. Ambos se comportan de forma distinta con las palabras clave hermanas de $ref y con las palabras clave relacionadas con arrays, así que no los mezcles dentro de un mismo conjunto de esquemas.

6. Para API de terceros sin documentación, deduce el contrato a partir de muestras de respuesta

Integrar una callback de terceros, trabajar con un socio que no escribe documentación o mantener un sistema heredado de diez años — todo eso significa "los datos están en la mano, la especificación no". Aquí un generador no es solo una comodidad; es la vía más rápida hacia un contrato.

El método es el mismo que arriba, con dos obligaciones extra:

Uno: muestrear en el tiempo y a través de estados. Los campos de terceros suelen cambiar con el estado del negocio — una promoción añade un discount, un pedido reembolsado añade un refundAt, y una respuesta de error no tiene data en absoluto. Tomar una única instantánea significa ver solo un estado.

Alimenta juntas varias respuestas correctas, varias fallidas y varios resultados vacíos: error y data suelen aparecer de forma mutuamente exclusiva, y la inferencia de varios registros coloca ambos en properties mientras los deja fuera de required — coincidiendo exactamente con la realidad.

Dos: guarda el esquema en el repositorio y adjunta una comprobación diaria. Valida las respuestas reales de hoy contra él, y te enterarás de campos que desaparecen en silencio o cambian de tipo antes que tus usuarios.

// Run once a day: contract-drift alert
const drifted = records.filter((record) => !validate(record));

if (drifted.length > 0) {
  console.warn(
    `${drifted.length} responses do not match the contract; first difference:`,
    validate.errors,
  );
}

El valor de esta práctica no es "ahorrar el esfuerzo de escribirlo a mano", sino convertir un acuerdo verbal en una alerta ejecutable. Un tercero nunca te avisa por adelantado de que ha cambiado un campo, pero el cambio aparecerá sin falta en tus logs.

Un límite que conviene trazar con claridad: el esquema deducido describe lo que la otra parte devuelve ahora, no lo que promete devolver. Lo primero sirve para monitorizar; solo lo segundo pertenece a un contrato o a la documentación de la API.

7. Tras completarlo, haz que los errores de validación apunten a campos concretos

Una vez que el esquema es usable, la siguiente preocupación son los errores legibles. Un objeto de error de ajv tiene cinco campos, y los cuatro primeros son los útiles:

Campo Significado Ejemplo (id: 0 incumpliendo minimum: 1)
instancePath JSON Pointer al valor infractor en los datos "/id"
schemaPath Ubicación de la regla que se dispara en el esquema "#/properties/id/minimum"
keyword Nombre de la restricción fallida "minimum"
params Detalles legibles por máquina de esa restricción { "comparison": ">=", "limit": 1 }
message Texto en inglés que cambia entre versiones, no lo analices "must be >= 1"

Hay una asimetría en la que es más fácil tropezar: cuando required falla, instancePath apunta al objeto padre (normalmente la cadena vacía, es decir, la raíz), no al campo ausente. El nombre de la clave ausente está en params.missingProperty. Del mismo modo, cuando additionalProperties falla, el nombre de la clave extra está en params.additionalProperty.

Así que al mapear errores a "campo → mensaje", atiende primero estos dos casos especiales:

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

/** Convert the ajv error array into { fieldPath: [message] } for form highlighting */
function groupByField(errors) {
  const grouped = {};

  for (const error of errors) {
    // For required and additionalProperties, the offending field lives in params, not in instancePath
    const field =
      error.keyword === "required"
        ? error.params.missingProperty
        : error.keyword === "additionalProperties"
          ? error.params.additionalProperty
          : error.instancePath.replace(/^\//u, "").split("/").join(".") ||
            "(root)";

    (grouped[field] ??= []).push(error.message);
  }

  return grouped;
}

if (!validate(data)) console.log(groupByField(validate.errors));

allErrors: true no es opcional. Con la configuración por defecto ajv se detiene en el primer error, así que el usuario corrige un campo, vuelve a enviar, recibe el siguiente error y necesita tres idas y vueltas solo para rellenar un formulario. Si además usas ajv-errors para mensajes personalizados, sin este flag el plugin reemplazará en silencio solo algunos de los mensajes.

8. Un proceso de despliegue reutilizable

Encadena los pasos anteriores en una rutina fija y síguela cada vez que integres una API nueva:

  1. Muestrear: extrae 5–20 respuestas reales, eligiendo deliberadamente las que tienen campos vacíos, nulls, arrays vacíos y campos extra;
  2. Inferir: pégalas juntas como un array en el generador, desactiva "todo obligatorio" y toma items;
  3. Completar la semántica: añade format, enum, minimum/maximum, minItems y additionalProperties elemento a elemento;
  4. Autocomprobar: las muestras originales deben pasar al 100%, de lo contrario vuelve al paso 1 y añade muestras;
  5. Prueba negativa: prepara una entrada "debería fallar" para cada restricción y confirma que realmente falla — probar solo el camino que pasa es lo mismo que no probar;
  6. Confirmar: pon el esquema bajo control de versiones, conserva las muestras que fallan como casos de prueba y ejecuta las pruebas primero siempre que la API cambie un campo.

La prueba negativa del paso 5 es la más fácil de omitir. Añadir una clave a required sin haber verificado nunca que una clave ausente da error significa apostar a que el validador coincide con tu modelo mental.

import { describe, expect, it } from "vitest";

describe("user schema", () => {
  it("accepts a complete record", () => {
    expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
  });

  it("rejects a record without name", () => {
    expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
    // Also assert the reason, so the test doesn't pass because some other field failed
    expect(
      validate.errors?.some((e) => e.params?.missingProperty === "name"),
    ).toBe(true);
  });
});

Esa última comprobación importa. Comprobar solo false puede seguir en verde incluso después de que el esquema se haya roto — el fallo puede venir para entonces de una causa completamente distinta.

9. Preguntas frecuentes

¿Puede el esquema que produce un generador ir directamente a producción?

No. Es solo una instantánea de la estructura. Como mínimo debes añadir formatos, rangos, enumeraciones y una política de campos desconocidos, y luego pasar tanto las muestras originales como las muestras negativas por él. El generador resuelve "escribir properties a mano es tedioso", no "decide las reglas de negocio".

Para el sistema de palabras clave de la autoría de esquemas, la división con $ref y las palabras clave de composición, consulta JSON Schema: de lo básico a producción.

¿Por qué el registro con más campos rechaza más datos?

Porque required se recoge de "lo que apareció en la muestra". Una muestra con todos los campos convierte cada clave opcional en obligatoria, mientras que la mayoría de esas claves están ausentes en los datos reales. Alimentar con varios registros, o con un registro que omite campos deliberadamente, te acerca más a la verdad que la muestra más completa.

¿Se inferirá un entero como integer?

El generador de este sitio no lo hace: tanto los enteros como los flotantes se emiten como number, así que un esquema generado desde {"price": 5} acepta 5.99. Eso evita accidentes como "la muestra resultó ser un entero, así que producción rechaza precios con decimales". Algunas herramientas de terceros sí infieren integer a partir del valor, así que ten especial cuidado con los campos de dinero.

Para detalles sobre la precisión numérica y los límites de tipos, consulta Los seis tipos de datos JSON y los problemas de precisión numérica.

Añadí format pero no tiene efecto

Desde draft 2019-09, format es solo anotación por defecto. O activas explícitamente las comprobaciones de formato en el validador (ajv necesita ajv-formats), o usas pattern para una restricción dura. No asumas que añadirlo bloqueará algo.

required informa un error, pero el campo está claramente presente en los datos

Mira primero instancePath. Un error required apunta al objeto padre, lo que significa que la clave ausente no está en ese nivel — las causas habituales son un nivel de anidamiento incorrecto (/profile/name no es lo mismo que name en la raíz), un uso de mayúsculas distinto (userId frente a userID), o que la serialización en origen elimina undefined por completo.

Antes de investigar, confirma que el propio documento es JSON válido: los comentarios, las comas finales y problemas similares fallan en la fase de parseo, antes de que el esquema tenga siquiera ocasión. Parseo y formateo JSON apunta directamente a la línea infractora.

¿Cuántos registros debería muestrear, y qué draft debería usar?

Muestrear 5–10 registros en el día a día da el mejor resultado; required converge con una mediana de 3 registros, y 12 registros cubren todas las familias. Cada registro debe venir de una situación genuinamente distinta, y cada forma de cada campo debe aparecer al menos una vez. Fija el draft a 2020-12, porque OpenAPI 3.1 acepta solo esa versión.

¿Por qué es más peligroso un additionalProperties permisivo por defecto?

El valor por defecto no emite ninguna palabra clave, así que una clave mal escrita se acepta en silencio, pasa la validación y luego falla en tiempo de ejecución — más difícil de diagnosticar que un error directo. Los esquemas de configuración deben establecer "additionalProperties": false explícitamente, mientras que los esquemas de API deberían sopesar primero el coste de compatibilidad.

¿Debería usar un esquema deducido como barrera estricta?

No. Un esquema deducido describe lo que la otra parte devuelve ahora, no lo que promete devolver, así que pertenece a la monitorización y a las alertas de deriva. Lo que vaya a un contrato o a la documentación de la API debe ser el contrato prometido.