JSON Toolkit

Cuando los errores de JSON Schema no tienen sentido: localización y gestión de mensajes en Ajv

En resumen: El validador emite errores legibles por máquina; los usuarios quieren saber qué casilla está mal. Cubre el array de errores de Ajv, instancePath frente a schemaPath, agrupación y redacción.

Avanzado~10 min de lecturaPublicado el 2026-10-31Actualizado el 2026-10-31
Diagrama de localización de errores de validación de Ajv y gestión de mensajes

En resumen: lee primero instancePath para localizar el dato que falla y luego schemaPath para localizar la regla que se disparó; para required y additionalProperties, extrae el nombre real de la clave de params antes de elegir el mensaje.

El trabajo del validador es decir qué regla falló. Traducir eso a "esta casilla está mal" es tu trabajo.

1. Qué te da realmente el array de errores

Ajv no lanza una excepción cuando la validación falla; deja el resultado en validate.errors. La configuración por defecto conserva solo el primer error, y allErrors es lo que produce una lista completa. Un objeto de error individual tiene cinco campos que importan, y entenderlos es la premisa de todo lo que viene después:

Campo Significado Ejemplo
instancePath JSON Pointer al valor que falla /email
schemaPath Ubicación de la palabra clave que falla en el esquema #/properties/email/format
keyword Nombre de la restricción incumplida format
params Parámetros legibles por máquina de esa restricción { "format": "email" }
message Texto en inglés que cambia entre versiones must match format "email"

Un error real se parece a esto:

[
  {
    "instancePath": "/email",
    "schemaPath": "#/properties/email/format",
    "keyword": "format",
    "params": { "format": "email" },
    "message": "must match format \"email\""
  }
]

Fíjate en que message es texto escrito para humanos, no una interfaz estable. Ajv puede reformularlo entre versiones y no puede servir a un producto localizado. Nunca lo analices con una expresión regular, y nunca lo devuelvas tal cual a un cliente.

2. instancePath y schemaPath responden a preguntas distintas

instancePath dice qué parte de los datos falló; schemaPath dice qué regla se disparó. Son dos sistemas de coordenadas y no son simétricos:

  • instancePath es un JSON Pointer de RFC 6901. Una cadena vacía significa la raíz; /items/0/price significa el campo price del primer elemento del array.
  • schemaPath empieza con # y apunta a la palabra clave dentro del documento del esquema. A través de un $ref, apunta dentro del esquema referenciado, así que necesitas el $id para saber de qué archivo viene.
  • Una clave que contiene / o ~ se escapa a ~1 y ~0 respectivamente. Deshaz ese escape antes de mostrarla, o la interfaz mostrará símbolos desconcertantes.

Por ejemplo, instancePath: "/email" junto con schemaPath: "#/properties/email/format" significa que el campo email del objeto raíz no superó la comprobación de formato.

La trampa más común son required y additionalProperties: su instancePath apunta al objeto padre, no a la clave infractora.

Palabra clave A dónde apunta instancePath Dónde está la clave real
required Al objeto padre (cadena vacía en la raíz) params.missingProperty
additionalProperties Al objeto padre params.additionalProperty
type Al propio valor que falla —
enum Al propio valor que falla params.allowedValues

Al mapear errores a campos de formulario, atiende primero estos dos casos. De lo contrario, "falta email" se convierte en "error del objeto raíz", y el usuario no tiene idea de dónde escribir.

3. allErrors y verbose: exhaustividad frente a coste

El valor por defecto allErrors: false hace que Ajv se detenga en el primer fallo. Eso está bien para una herramienta de línea de comandos y es desastroso para un formulario: el usuario corrige un campo, envía, ve el siguiente error y repite tres veces antes de que el formulario pase.

Ajuste Comportamiento Beneficio Coste
allErrors: false Solo el primer error Lo más rápido, menor uso de memoria Envíos repetidos
allErrors: true Recoge todos los errores Todos los problemas en una pasada El número de errores se dispara con esquemas complejos
verbose: true Adjunta schema y data a cada error Diagnóstico más fácil en el servidor Tamaño muy aumentado; nunca lo expongas
messages: false Elimina el campo message Cargas útiles más pequeñas Pierdes la pista de depuración en inglés

Reparto recomendado: activa allErrors: true para la validación en servidor y conserva el array completo en los logs internos, mientras que la respuesta pública lleva solo campos de la lista blanca. Activa verbose solo mientras investigas; incrusta el fragmento de esquema coincidente y los datos del usuario en cada error, lo que consume memoria y puede filtrar valores sensibles a los logs.

El volumen de errores es el otro problema frecuente. Una rama anyOf fallida emite varios errores hijos más un error resumen, y oneOf con muchas ramas es peor. Muestra el array tal cual y los usuarios verán cinco mensajes casi duplicados para un mismo campo. La agrupación tiene que ocurrir antes del renderizado.

4. Agrupar errores por campo

Agrupar hace tres cosas: convertir la ruta de vuelta en un nombre de campo, corregir el desplazamiento de required y additionalProperties, y fusionar varios errores de un mismo campo en el mensaje único más accionable.

/** One validation error ready for display */
interface FieldError {
  field: string;
  keyword: string;
  params: Record<string, unknown>;
}

/**
 * Group Ajv errors by field.
 * @param errors Error array produced by Ajv
 * @returns Map keyed by field path, each value listing its errors
 */
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
  const grouped: Record<string, FieldError[]> = {};

  for (const errorItem of errors) {
    const keyword: string = errorItem.keyword;
    let field: string = errorItem.instancePath;

    if (keyword === 'required') {
      const parentPath: string = errorItem.instancePath;
      field = `${parentPath}/${errorItem.params.missingProperty}`;
    } else if (keyword === 'additionalProperties') {
      field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
    }

    const normalizedField = field === '' ? '(root)' : field;
    (grouped[normalizedField] ??= []).push({
      field: normalizedField,
      keyword,
      params: errorItem.params ?? {},
    });
  }

  return grouped;
}

Fusiona con un orden de prioridad: required (falta) primero, luego type, luego enum/const, luego format/pattern, y por último longitud y rango. Muestra solo el mensaje de mayor prioridad por campo y guarda el resto en los logs. La razón es sencilla: cuando el tipo es incorrecto, un mensaje de longitud es ruido, y corregir el tipo es el único siguiente paso sensato.

Los errores resumen de oneOf y anyOf normalmente no aportan información accionable. Omítelos durante el renderizado y conserva solo los errores de rama que apuntan a un campo concreto.

5. Mensajes personalizados: la vía del plugin y la vía de la plantilla

Ajv no incluye redacción localizada, así que hay dos opciones prácticas.

Opción uno: el plugin ajv-errors. Añade una palabra clave errorMessage para que los mensajes vivan junto a las restricciones:

{
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "errorMessage": {
        "format": "Please enter a valid email address",
        "type": "Email must be a string"
      }
    }
  }
}

La ventaja es que la redacción viaja con el campo y el esquema hace también de documentación. El inconveniente es que el texto queda congelado en un idioma, así que localizar significa mantener varios esquemas. El plugin además solo reemplaza todos los mensajes cuando allErrors: true está activado; olvida ese flag y obtendrás una mezcla de mensajes personalizados y predeterminados en inglés.

Opción dos: una tabla de palabra clave a plantilla. El validador solo proporciona keyword y params; la aplicación produce la redacción:

Palabra clave Plantilla Parámetros disponibles
required Falta el campo obligatorio: {field} missingProperty
type Tipo esperado: {type} type
minLength Introduce al menos {limit} caracteres limit
maximum No debe superar {limit} limit
format Formato no válido: {format} format
enum Debe ser una de las opciones permitidas allowedValues
pattern El valor no coincide con el formato esperado pattern (no lo repitas)

La opción dos encaja en productos que necesitan varios idiomas y una voz coherente. Separa la validación de la presentación, mantiene la traducción fuera del esquema y permite que el frontend reutilice las mismas plantillas.

6. Localización: los mensajes no pertenecen al validador

Mover la redacción a los paquetes de idioma es el punto de partida de la gestión de mensajes. Un paquete de idioma almacena plantillas y claves, nunca nombres de campo; las etiquetas de campo vienen de la configuración del formulario o del contrato de la API, y ambas se combinan en el momento del renderizado.

/** Validation message templates keyed by Ajv keyword */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
  required: (params) => `Missing required field: ${params.missingProperty}`,
  type: (params) => `Expected type: ${params.type}`,
  minLength: (params) => `Enter at least ${params.limit} characters`,
  maximum: (params) => `Must not exceed ${params.limit}`,
  format: () => 'Invalid format',
};

/**
 * Build one user-facing validation message.
 * @param keyword Name of the failed constraint
 * @param params Constraint parameters
 * @returns Localized message text
 */
function buildMessage(keyword: string, params: Record<string, any>): string {
  const template = VALIDATION_TEMPLATES[keyword];
  return template ? template(params) : 'The value is invalid';
}

Aquí importan tres reglas:

  1. No concatenes frases. "Email format is invalid" y su equivalente en chino no comparten orden de palabras, y el ensamblado de cadenas acabará produciendo sinsentidos en uno de los dos.
  2. Delega los plurales y los clasificadores a la biblioteca de i18n. Nunca fijes "characters" en una plantilla; 1 character y 3 characters son distintos.
  3. Traduce también las etiquetas de campo. params.missingProperty es email, mientras que la interfaz debería decir "Correo electrónico". Mantén ese mapeo en la configuración del formulario, no en el paquete de idioma.

7. No devuelvas la estructura interna a los clientes

Devolver schemaPath, params y message juntos es cómodo durante el desarrollo y una filtración de información en producción. allowedValues revela enumeraciones internas, pattern revela expresiones regulares de validación y schemaPath revela la disposición de archivos y la nomenclatura de campos — en algunos casos entregando a los atacantes un mapa de tus puntos débiles.

La respuesta pública conserva exactamente tres cosas: una ruta de campo (o el nombre del campo del formulario), un código de error estable y texto para el usuario. Usa códigos breves y estables en lugar del keyword en bruto, para conservar margen de cambio de validador más adelante.

{
  "type": "about:blank",
  "title": "Request validation failed",
  "status": 422,
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "Please enter a valid email address" },
    { "field": "items[0].price", "code": "invalid_pattern", "message": "Amount must have exactly two decimals" }
  ]
}

Los logs del servidor deberían contener lo contrario: el array completo de errores, el id de la petición y la versión del esquema, para que una investigación pueda responder "qué regla bloqueó esto". Evita volcar cuerpos de petición completos en los logs; conserva en su lugar las rutas y palabras clave que fallan.

8. Presentación en el frontend: del resaltado de campo a los marcadores del editor

Los errores acaban en una interfaz, y la presentación depende del contexto:

Contexto Presentación Acción clave
Formulario Texto rojo bajo el campo, enfocar el primer error Mapear rutas a nombres de formulario; conservar los índices de array
Importación masiva de tablas Número de fila, nombre de columna, resumen, exportable Convertir /items/3/price en fila 4, columna price
Editor JSON Marcadores en línea y un panel de problemas Usar un parser para mapear JSON Pointer a línea y columna
Herramienta de depuración de API Lista de errores estructurada que conserva schemaPath De cara al desarrollador, así que se acepta más detalle

Tres notas de implementación:

  • Muestra solo el primer error por campo, colapsando el resto en "2 problemas más", para que el formulario no se ahogue en rojo.
  • Conserva los índices de array para apuntar pero elimínalos de las etiquetas. "Fila 4, precio" se lee mejor que /items/3/price.
  • Los escenarios de editor necesitan mapeo de posiciones. Una búsqueda de cadena simple no es fiable cuando un nombre de campo se repite; el parser debería proporcionar desplazamientos para cada nodo, que luego conviertes a línea y columna.

Si el JSON viene de un usuario que escribe en lugar de un formulario, confirma primero que el documento es sintácticamente válido. Los errores de sintaxis ocurren durante el parseo y nunca llegan a la validación de esquema. Parseo y formateo JSON informa de la línea y columna exactas, lo que es mucho más rápido que perseguir errores de esquema. Para saber cómo surgen esquemas demasiado estrictos cuando se infieren de muestras, consulta Por qué el JSON Schema autogenerado sigue rechazando datos reales; para la configuración del validador y el sistema de palabras clave, consulta JSON Schema: de lo básico a producción.

9. Preguntas frecuentes

¿Por qué solo obtengo un error?

allErrors vale false por defecto, así que Ajv devuelve el resultado en el primer fallo. Actívalo explícitamente para la validación de formularios; de lo contrario los usuarios tienen que enviar repetidamente para descubrir cada problema.

¿Por qué la ruta de un error required está vacía?

El instancePath de required apunta al objeto padre, que en la raíz es la cadena vacía; el nombre de la clave ausente está en params.missingProperty. Concatena ambos para obtener la ruta completa. Mostrar solo instancePath produce el inútil "error del objeto raíz".

¿Puedo mostrar message directamente a los usuarios?

No es aconsejable. Es texto en inglés cuya redacción cambia entre versiones, y no tiene capacidad de localización. Genera el texto a partir de keyword y params y reserva message para los logs.

¿Por qué se ignora mi mensaje de error personalizado?

Comprueba primero que allErrors: true está activado; ajv-errors solo puede reemplazar todos los mensajes con ese flag. Luego confirma que errorMessage está al mismo nivel que la palabra clave que falla — un nivel de diferencia y no se reconocerá.

¿Qué campos son seguros de devolver en producción?

La ruta del campo, un código de error estable y el texto para el usuario. Conserva schemaPath, params y message en los logs del servidor: eso permite depurar sin exponer enumeraciones internas, expresiones regulares o la disposición de archivos.