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

Lendo erros de validação

Um parse que falha devolve um erro. Se você só der um console.log nele, parece uma parede de texto sem saída.

Mas não é. Tudo que você precisa para colocar uma mensagem no campo certo do formulário está ali, num formato pensado para ser lido por código.

Comece com um schema que dois valores conseguem quebrar:

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 })

O array de issues

O conteúdo útil do erro fica em .issues, e é um array porque um único parse pode encontrar vários problemas de uma vez:

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

Dois valores estavam errados, então há duas issues. Cada uma é um objeto descrevendo um único problema:

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

Quatro campos que vale a pena conhecer:

CampoO que ele indica
codeO tipo de falha, como invalid_type ou too_small
pathQual chave falhou, como um array
messageUma frase descrevendo o problema
expectedO que o schema esperava, em falhas de tipo

A comparação com o scanner continua valendo aqui. A mensagem do console é o resumo que a máquina imprime no visor. issues é o relatório detalhado por trás dela, e é com esse que você trabalha.

JunoO array de issues A parte confusa é que, ao logar o erro, você vê uma mensagem, então parece que só existe uma mensagem.

O array está lá o tempo todo. Use .issues e você recebe a versão estruturada, uma entrada para cada coisa que deu errado.

JunoO array de issues Duas coisas decorrem do fato de ser um array. A validação não para na primeira falha, então um formulário pode mostrar todos os problemas de uma vez, em vez de forçar a pessoa a corrigir um campo por envio.

E issues[0] é um atalho do qual você vai se arrepender. Pegar a primeira entrada funciona enquanto você está testando com um único campo quebrado, mas esconde silenciosamente o resto no momento em que um usuário real erra dois campos.

JunoO array de issuescode é o campo certo para decidir comportamentos diferentes, porque ele é estável de um jeito que as mensagens não são. Um too_small numa senha é um usuário se corrigindo; uma sequência de invalid_type em todos os campos geralmente significa que um cliente está enviando o tipo de conteúdo totalmente errado, e vale a pena logar isso de forma diferente.

Os objetos de issue carregam chaves extras dependendo do código. Uma issue too_small inclui minimum e inclusive, verificado no Zod 4.5.4, o que permite você escrever "precisa ter pelo menos 18" uma única vez e ler o número direto da issue em vez de repeti-lo numa string de mensagem.

Uma regra para a resposta, porém: o que você mostra ao cliente e o que você loga são documentos diferentes. Nomes de campos e restrições tudo bem devolver. O valor recebido não, porque a entrada rejeitada pode incluir senhas e tokens, e uma resposta de erro é um lugar onde as pessoas esquecem que estão registrando dados.

Qual campo falhou

path é um array em vez de uma string, porque uma chave pode estar aninhada:

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' ]

Para um formulário simples, o primeiro elemento é o nome do campo, o que já basta para construir o que um formulário precisa:

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' }
// ]

Um nome de campo e uma frase, um para cada problema. É esse o formato de que um formulário precisa para colocar cada mensagem ao lado do campo a que ela pertence.

JunoQual campo falhou Um array parece exagero para nomear um único campo, até os dados terem camadas.

['user', 'profile', 'email'] é um conjunto de instruções: entre em user, depois em profile, depois em email. Uma string simples não conseguiria dizer isso sem você ter que desmontá-la de novo.

JunoQual campo falhoupath[0] funciona bem para um formulário simples e para de funcionar assim que algo se aninha, porque aí ele agrupa todos os campos sob um mesmo pai na mesma chave e as mensagens acabam indo parar no lugar errado.

issue.path.join('.') te dá user.profile.email, que continua único. Vale a pena escrever assim desde o início, já que não custa nada e sobrevive ao primeiro objeto aninhado.

JunoQual campo falhou Índices de array aparecem no path como números, então uma falha dentro de uma lista te dá algo como ['items', 2, 'quantity']. Se você juntar isso, obtém items.2.quantity, o que é aceitável para uma linha de log, mas não é o que uma biblioteca de formulário espera; a maioria quer items[2].quantity. Vale a pena tratar isso uma única vez, em qualquer função que mapeia issues para o seu formulário.

O Zod já vem com um helper para o caso comum: z.flattenError(result.error) retorna { formErrors, fieldErrors }, em que fieldErrors mapeia cada chave de nível superior para um array de strings de mensagem. Verificado no Zod 4.5.4. É o caminho mais rápido até um formulário, e ele achata o aninhamento por design, então serve bem para um formulário raso e não para um profundo. Vale notar que no Zod 3 esse recurso era um método .flatten() no erro; no Zod 4 é a função de nível superior.

Escrevendo suas próprias mensagens

As mensagens padrão descrevem o sistema de tipos, não o seu formulário. "Invalid input: expected string, received number" está correta e não pertence ao usuário de ninguém.

Quase todo método do Zod aceita uma mensagem como argumento:

js
const teacherSchema = z.object({
  name: z.string('Please enter your name'),
  age: z.number().min(18, 'Teachers must be at least eighteen'),
})

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

console.log(result.error.issues.map((issue) => issue.message))
// [ 'Please enter your name', 'Teachers must be at least eighteen' ]

A mensagem substitui o padrão apenas para aquela checagem específica. Cada restrição carrega a sua própria, então um campo pode dizer uma coisa quando está vazio e outra quando é muito curto.

JunoEscrevendo suas próprias mensagens Escreva essas mensagens do jeito que você diria para a pessoa que está preenchendo o formulário. "Por favor, digite seu nome" ganha de qualquer coisa que mencione tipos.

Quem lê não está depurando o seu schema. Está tentando se cadastrar.

JunoEscrevendo suas próprias mensagens Como a mensagem é por checagem, e não por campo, um campo com três restrições precisa de três mensagens, e pular uma delas deixa um padrão no meio do seu texto cuidadosamente escrito.

Diga o que fazer, em vez de o que deu errado. "Use pelo menos 12 caracteres" é acionável; "String must contain at least 12 character(s)" faz a pessoa ter que adivinhar o que você quis dizer.

JunoEscrevendo suas próprias mensagens Mensagens customizadas também marcam o ponto em que um erro deixa de ser seguro para devolver sem alteração. Uma mensagem que você escreveu é sua; uma mensagem padrão é uma descrição do seu schema, e um cliente que as coleta aprende exatamente o formato e as restrições da sua API.

Isso é de baixa gravidade num formulário de cadastro e vale a pena pensar a respeito num endpoint interno. O padrão que escala é ter uma mensagem em toda checagem que o cliente deveria ver, e uma resposta genérica para qualquer coisa que falhe sem uma mensagem definida.

Para localização, strings por chamada são a camada errada, porque você acabaria tendo que passar um idioma por todas as definições de schema. O Zod oferece um error map global em vez disso, então a tradução acontece uma única vez, no ponto em que você formata as issues para uma resposta.

Coloque em prática

Dado este schema e um personagem que quebra as duas regras dele:

js
const characterSchema = z.object({
  name: z.string('Every character needs a name'),
  episode: z.number().min(1, 'Episodes start at 1'),
})

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

Seis lacunas, marcadas com ___. Cada uma de success, result, error, issues, path e message se encaixa em exatamente uma delas:

js
const ___ = characterSchema.safeParse(character)

if (result.___) {
  console.log('All good')
} else {
  console.log(
    result.___.___.map((issue) => ({
      field: issue.___[0],
      message: issue.___,
    })),
  )
}
Compare suas respostas
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,
    })),
  )
}

Resolvendo em ordem:

  • A primeira lacuna nomeia o que safeParse retorna, e tudo abaixo lê result, então tem que ser result.
  • result.success é o booleano que decide qual ramo roda.
  • result.error só existe no ramo de falha, e é por isso que fica dentro do else.
  • .issues é o array, então o .map precisa dele.
  • issue.path[0] é o nome do campo, indexado porque path é um array.
  • issue.message é a frase.

A cadeia se lê como uma única frase quando faz sentido: as issues do error do result, cada uma com um path e uma message.

As duas regras quebram, então são logadas duas entradas:

js
// [
//   { field: 'name', message: 'Every character needs a name' },
//   { field: 'episode', message: 'Episodes start at 1' }
// ]

Para onde isso leva

Três capítulos de Zod, tudo rodado manualmente com objetos inventados. O schema está fazendo um trabalho de verdade, mas nada ainda está conectado à aplicação.

Onde a validação se encaixa conecta isso à aplicação, colocando a checagem entre uma requisição que chega e o código que age sobre ela.