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.

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:
instancePathes un JSON Pointer de RFC 6901. Una cadena vacía significa la raíz;/items/0/pricesignifica el campo price del primer elemento del array.schemaPathempieza 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$idpara saber de qué archivo viene.- Una clave que contiene
/o~se escapa a~1y~0respectivamente. 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:
- 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.
- Delega los plurales y los clasificadores a la biblioteca de i18n. Nunca fijes "characters" en una plantilla;
1 charactery3 charactersson distintos. - Traduce también las etiquetas de campo.
params.missingPropertyesemail, 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.