Skip to content
This page has been auto-translated and may contain errors.View in English

Cómo leer errores de validación

Un parseo fallido te devuelve un error. Si lo muestras directo en la consola, parece un muro de texto sin salida.

Pero no lo es. Todo lo que necesitas para mostrar un mensaje junto al campo correcto está ahí, en un formato pensado para que el código lo pueda leer.

Empecemos con un schema que dos valores pueden romper:

js
import * as z from 'zod'

const teacherSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
})

const result = teacherSchema.safeParse({ name: 12345, age: 13 })

El arreglo de issues

El contenido útil del error vive en .issues, y es un arreglo porque un solo parseo puede encontrar varios problemas a la vez:

js
console.log(result.error.issues.length)
// 2

Dos valores estaban mal, así que hay dos issues. Cada uno es un objeto que describe un solo problema:

js
console.log(result.error.issues[0])
// {
//   expected: 'string',
//   code: 'invalid_type',
//   path: [ 'name' ],
//   message: 'Invalid input: expected string, received number'
// }

Cuatro campos que vale la pena conocer:

CampoQué te dice
codeEl tipo de falla, como invalid_type o too_small
pathQué clave falló, como un arreglo
messageUna oración que describe el problema
expectedQué esperaba el schema, en fallas de tipo

La comparación con el escáner sigue siendo válida acá. El mensaje de la consola es el resumen que la máquina imprime en su pantalla. issues es el informe detallado que hay debajo, y ese es con el que en realidad trabajas.

JunoEl arreglo de issues Lo confuso es que cuando muestras el error en consola ves un mensaje, así que parece que eso es todo lo que hay.

El arreglo está ahí todo el tiempo. Si usas .issues obtienes la versión estructurada, con una entrada por cada cosa que salió mal.

JunoEl arreglo de issues De que sea un arreglo se derivan dos cosas. La validación no se detiene en la primera falla, así que un formulario puede mostrar todos los problemas de una vez en lugar de obligar a alguien a corregir un campo por cada intento.

Y issues[0] es un atajo del que te vas a arrepentir. Usar solo la primera entrada funciona mientras pruebas con un único campo roto, pero esconde el resto en cuanto un usuario real se equivoca en dos campos.

JunoEl arreglo de issuescode es el campo por el que conviene ramificar cuando el comportamiento tiene que cambiar, porque es estable de una forma en que los mensajes no lo son. Un too_small en una contraseña es un usuario corrigiéndose a sí mismo; una serie de invalid_type en todos los campos normalmente significa que un cliente está enviando el tipo de contenido equivocado por completo, y conviene registrarlo distinto en el log.

Los objetos issue traen claves adicionales según el code. Un issue de tipo too_small incluye minimum e inclusive, verificado en Zod 4.5.4, lo que te permite escribir "debe tener al menos 18" una sola vez y leer el número desde el issue en vez de repetirlo en el texto del mensaje.

Eso sí, hay una regla para la respuesta: lo que le muestras al cliente y lo que registras en el log son documentos distintos. Los nombres de campo y las restricciones se pueden devolver sin problema. El valor recibido no, porque el input rechazado puede incluir contraseñas y tokens, y una respuesta de error es un lugar donde se suele olvidar que se está guardando esa información.

Qué campo falló

path es un arreglo en vez de un string, porque una clave puede estar anidada:

js
const orderSchema = z.object({
  user: z.object({
    profile: z.object({
      email: z.email(),
    }),
  }),
})

const bad = orderSchema.safeParse({ user: { profile: { email: 'nope' } } })

console.log(bad.error.issues[0].path)
// [ 'user', 'profile', 'email' ]

En un formulario plano el primer elemento es el nombre del campo, y eso alcanza para construir lo que un formulario necesita:

js
const errors = result.error.issues.map((issue) => ({
  field: issue.path[0],
  message: issue.message,
}))

console.log(errors)
// [
//   { field: 'name', message: 'Invalid input: expected string, received number' },
//   { field: 'age', message: 'Too small: expected number to be >=18' }
// ]

Un nombre de campo y una oración, uno por problema. Ese es el formato que necesita un formulario para colocar cada mensaje junto al input al que corresponde.

JunoQué campo falló Un arreglo parece exagerado para nombrar un solo campo, hasta que los datos tienen capas.

['user', 'profile', 'email'] es una serie de indicaciones: entra a user, luego a profile, luego a email. Un simple string no podría decir eso sin que tú tuvieras que volver a descomponerlo.

JunoQué campo fallópath[0] funciona bien en un formulario plano, pero deja de servir en cuanto algo se anida, porque en ese caso mete todos los campos de un mismo padre bajo la misma clave y los mensajes terminan en el lugar equivocado.

issue.path.join('.') te da user.profile.email, que se mantiene único. Vale la pena escribirlo así desde el principio, porque no cuesta nada y sigue funcionando en cuanto aparece el primer objeto anidado.

JunoQué campo falló Los índices de arreglo aparecen en el path como números, así que una falla dentro de una lista te da algo como ['items', 2, 'quantity']. Si lo unes con join obtienes items.2.quantity, que sirve para una línea de log pero no es lo que espera una librería de formularios; la mayoría espera items[2].quantity. Conviene resolver esto una sola vez en el lugar donde mapeas los issues a tu formulario.

Zod trae un helper para el caso común: z.flattenError(result.error) devuelve { formErrors, fieldErrors }, donde fieldErrors asocia cada clave de primer nivel con un arreglo de strings de mensaje. Verificado en Zod 4.5.4. Es la ruta más rápida hacia un formulario, y por diseño aplana los datos anidados, así que sirve bien para un formulario plano y no tanto para uno profundo. Ten en cuenta que en Zod 3 esto era un método .flatten() sobre el error; en Zod 4 es una función de nivel superior.

Escribe tus propios mensajes

Los mensajes por defecto describen el sistema de tipos, no tu formulario. "Invalid input: expected string, received number" es preciso, pero no le sirve a ningún usuario real.

Casi todos los métodos de Zod aceptan un mensaje como argumento:

js
const teacherSchema = z.object({
  name: z.string('Por favor ingresa tu nombre'),
  age: z.number().min(18, 'Los profesores deben tener al menos dieciocho años'),
})

const result = teacherSchema.safeParse({ name: 12345, age: 13 })

console.log(result.error.issues.map((issue) => issue.message))
// [ 'Por favor ingresa tu nombre', 'Los profesores deben tener al menos dieciocho años' ]

El mensaje reemplaza el valor por defecto solo para esa comprobación. Cada restricción tiene el suyo propio, así que un campo puede decir una cosa cuando falta y otra cuando es demasiado corto.

JunoEscribe tus propios mensajes Escribe estos mensajes como se los dirías a la persona que está llenando el formulario. "Por favor ingresa tu nombre" le gana a cualquier cosa que mencione tipos de datos.

Quien lee esto no está depurando tu schema. Está tratando de registrarse.

JunoEscribe tus propios mensajes Como el mensaje es por comprobación y no por campo, un campo con tres restricciones necesita tres mensajes, y si te saltas uno, queda un mensaje por defecto en medio de tu texto tan cuidado.

Di qué hay que hacer en vez de qué salió mal. "Usa al menos 12 caracteres" es accionable; "String must contain at least 12 character(s)" obliga a la persona a adivinar qué quisiste decir.

JunoEscribe tus propios mensajes Los mensajes personalizados también son el punto donde un error deja de ser seguro para devolver sin cambios. Un mensaje que escribiste tú es tuyo; uno por defecto es una descripción de tu schema, y un cliente que los va recolectando termina conociendo la forma exacta y las restricciones de tu API.

Eso es de bajo riesgo en un formulario de registro y vale la pena pensarlo en un endpoint interno. El patrón que escala bien es tener un mensaje en cada comprobación que el cliente deba ver, y una respuesta genérica para cualquier falla que no tenga uno.

Para la localización, los strings por llamada son la capa equivocada, porque terminarías pasando un idioma a través de cada definición de schema. Zod soporta en cambio un mapa de errores global, así que la traducción ocurre una sola vez, en el punto donde formateas los issues para armar una respuesta.

Ponlo en práctica

Dado este schema y un personaje que rompe sus dos reglas:

js
const characterSchema = z.object({
  name: z.string('Todo personaje necesita un nombre'),
  episode: z.number().min(1, 'Los episodios empiezan en 1'),
})

const character = { name: 42, episode: 0 }

Seis espacios en blanco, marcados con ___. Cada uno de success, result, error, issues, path y message encaja exactamente en uno de ellos:

js
const ___ = characterSchema.safeParse(character)

if (result.___) {
  console.log('All good')
} else {
  console.log(
    result.___.___.map((issue) => ({
      field: issue.___[0],
      message: issue.___,
    })),
  )
}
Compara tus respuestas
js
const result = characterSchema.safeParse(character)

if (result.success) {
  console.log('All good')
} else {
  console.log(
    result.error.issues.map((issue) => ({
      field: issue.path[0],
      message: issue.message,
    })),
  )
}

Resolviéndolo en orden:

  • El primer espacio nombra lo que devuelve safeParse, y todo lo que sigue usa result, así que tiene que ser result.
  • result.success es el booleano que decide qué rama se ejecuta.
  • result.error solo existe en la rama fallida, por eso va dentro del else.
  • .issues es el arreglo, así que .map lo necesita.
  • issue.path[0] es el nombre del campo, con índice porque path es un arreglo.
  • issue.message es la oración.

La cadena se lee como una sola oración cuando encaja: los issues del error del resultado, cada uno con un path y un message.

Las dos reglas fallan, así que se registran dos entradas:

js
// [
//   { field: 'name', message: 'Todo personaje necesita un nombre' },
//   { field: 'episode', message: 'Los episodios empiezan en 1' }
// ]

Hacia dónde va esto ahora

Tres capítulos de Zod, todos ejecutados a mano sobre objetos inventados. El schema está haciendo trabajo real, pero todavía no está conectado a la aplicación.

Dónde encaja la validación la conecta con la aplicación, ubicando la comprobación entre una solicitud entrante y el código que actúa sobre ella.