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

Inferindo tipos e convertendo entradas

Um schema que aceita ou lança uma exceção já é suficiente para descrever dados. Para construir algo com ele, faltam mais duas coisas: uma forma de usar essa estrutura no seu próprio código, e uma forma de lidar com uma falha sem precisar de uma exceção.

Comece com um schema descrevendo um professor:

js
import * as z from 'zod'

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

Um schema, duas funções

Em TypeScript, normalmente você escreveria essa estrutura uma segunda vez:

ts
type Teacher = {
  name: string
  age: number
}

Agora a mesma estrutura vive em dois lugares, e eles vão divergir assim que alguém adicionar um campo em apenas um deles.

O z.infer extrai o tipo diretamente do schema:

ts
type Teacher = z.infer<typeof teacherSchema>
// { name: string; age: number }

Adicione uma chave ao schema e o tipo acompanha sozinho. O schema é a definição, e o TypeScript apenas a lê.

JunoUm schema, duas funções O typeof ali parece estranho porque não é o typeof do JavaScript que você já conhece. Essa é a versão do TypeScript, que pergunta "qual é o tipo desse valor" no nível dos tipos.

Você pode tratar a linha inteira como uma frase só: me dê o tipo que esse schema descreve.

JunoUm schema, duas funções A vantagem prática é que o tipo nunca fica desatualizado. Adicione email ao schema e toda função que recebe um Teacher passa a exigi-lo, então o compilador te mostra cada lugar que precisa ser atualizado.

Se você escrever o tipo manualmente, ele continua descrevendo a estrutura antiga silenciosamente, o que é pior do que não ter tipo nenhum: o código afirma uma garantia que não existe mais.

JunoUm schema, duas funções Os tipos de entrada e de saída podem ser diferentes, e isso passa a importar assim que valores padrão e conversões entram em cena. Um schema com .default() aceita um objeto sem essa chave e devolve um objeto com ela, então o que você pode passar e o que você recebe de volta são duas estruturas diferentes.

O z.infer te dá o tipo de saída, que é o que você quer usar quase sempre, porque você deveria estar lendo o valor já validado, e não a entrada bruta. Quando você realmente precisa do outro lado, para tipar o que quem chama a função pode enviar, existe o z.input. Precisar dele geralmente é sinal de que o valor bruto está sendo usado em algum lugar que não deveria.

Falha sem exceção

O parse lança uma exceção. Isso serve bem para um ponto de entrada onde uma requisição ruim deve interromper tudo, mas não serve para nada quando você quer examinar o que deu errado.

O safeParse devolve um resultado em vez disso:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: 21 })

console.log(result)
// { success: true, data: { name: 'Jonathan', age: 21 } }

Quando falha, a estrutura muda:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: '21' })

console.log(result.success)
// false

Um sucesso te dá success: true e data. Uma falha te dá success: false e error. Assim, um formulário pode fazer a pergunta e decidir o que fazer:

js
if (result.success) {
  console.log('Seguro para enviar adiante:', result.data)
} else {
  console.log('Mostre ao usuário o que deu errado:', result.error.issues)
}
JunoFalha sem exceção As duas versões fazem a mesma verificação. A diferença está no que elas te entregam quando a resposta é não.

O parse lança uma exceção, o que interrompe tudo a menos que você a capture. O safeParse retorna um objeto com uma flag success, então você pode perguntar e continuar.

JunoFalha sem exceção Escolha de acordo com o que deve acontecer em seguida. Em uma rota onde um corpo de requisição ruim significa apenas uma resposta 400 e mais nada, usar parse dentro do seu middleware de erros é uma solução limpa. Em um formulário onde uma falha significa mostrar três mensagens ao lado de três campos, o safeParse é a escolha certa.

Um hábito que vale a pena criar: leia sempre result.data, nunca o objeto original. Hoje eles são iguais, mas deixam de ser no instante em que um valor padrão ou uma conversão entra no schema.

JunoFalha sem exceção O resultado é uma união discriminada, então o TypeScript a estreita automaticamente para você. Dentro de um bloco if (result.success), result.data está tipado e result.error não existe; no bloco else, é o contrário. Verificar success primeiro não é uma preferência de estilo, é a forma de ter acesso a qualquer um dos dois campos.

Vale saber que parse e safeParse fazem exatamente o mesmo trabalho. O safeParse não é um modo mais permissivo, ele detecta as mesmas falhas e apenas as reporta de outra forma. Também não há diferença relevante de desempenho entre os dois, então a escolha deve se basear só no fluxo de controle.

Restringindo o que você aceita

Um tipo é um filtro grosseiro. z.number() aceita tanto -4 quanto 9e99, e nenhuma das duas é a idade de um professor.

Métodos são encadeados ao schema para deixá-lo mais restrito:

js
const teacherSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
  isAmerican: z.boolean().optional(),
  id: z.number().default(() => Math.random()),
})
  • .min(18) rejeita qualquer valor abaixo de dezoito. .gte() e .lte() fazem o mesmo trabalho com comparações explícitas.
  • .optional() permite que a chave esteja totalmente ausente.
  • .default() fornece um valor quando a chave está ausente, então o objeto validado sempre tem um.

O Zod também já vem com verificações prontas para formatos comuns, então um e-mail é uma única chamada:

js
const contactSchema = z.object({
  email: z.email(),
})

contactSchema.parse({ email: '[email protected]' })  // ok
contactSchema.parse({ email: 'jabbahuttcorp.com' })   // ZodError
JunoRestringindo o que você aceita Cada uma dessas linhas soa como uma frase se você a ler em voz alta. Um número, no mínimo dezoito. Um booleano, opcional. Um número, com um valor padrão.

É isso que a biblioteca quer dizer com declarativo: você descreve o resultado que quer, e ela cuida da verificação.

JunoRestringindo o que você aceita Os máximos são o que as pessoas esquecem, e são justamente eles que importam para o tipo de abuso mencionado no início desta seção. Um campo com um mínimo e sem máximo ainda assim aceita dez milhões de caracteres.

Coloque um .max() em toda string que você armazena. É a resposta mais barata possível para o problema de entradas gigantescas, e ela deve estar no schema, e não espalhada pelos handlers.

JunoRestringindo o que você aceita.default() tem uma armadilha na qual o primeiro exemplo que qualquer pessoa escreve cai direto. Escrever .default(Math.random()) chama a função uma única vez, no momento em que o schema é construído, então todo parse durante a vida do processo recebe exatamente o mesmo valor. Verificado no Zod 4.5.4: dois parses de um objeto vazio retornam o mesmo número.

Passe uma função em vez disso, .default(() => Math.random()), e ela é avaliada a cada parse. Dois parses, dois números. O mesmo vale para Date.now() e para qualquer id gerado, e a falha é silenciosa, porque um valor padrão que nunca muda ainda parece um valor padrão.

Sobre z.email(): é uma verificação de formato, e nenhuma expressão regular consegue decidir se um endereço realmente recebe e-mails. Trate-a como uma forma de rejeitar o que é obviamente malformado, e trate um link de confirmação como o que de fato comprova que o endereço é real.

Convertendo o que chega

Campos de formulário HTML enviam strings. Todos eles, inclusive o campo rotulado "idade" com um seletor numérico ao lado.

Então um schema que espera z.number() rejeita uma entrada de formulário perfeitamente válida, porque '13' é uma string. A conversão transforma o valor primeiro, e só depois valida:

js
const characterSchema = z.object({
  name: z.string(),
  episode: z.coerce.number(),
})

characterSchema.parse({ name: 'Luke Skywalker', episode: '4' })
// { name: 'Luke Skywalker', episode: 4 }

A string entrou, um número saiu, e qualquer outra verificação nessa chave rodou sobre o número.

JunoConvertendo o que chega A ordem importa aqui, e é o contrário do que você poderia imaginar. A conversão acontece primeiro, e só depois a verificação roda sobre o resultado.

Então z.coerce.number().min(18) converte o texto em número e só então pergunta se esse número é pelo menos dezoito.

JunoConvertendo o que chega Use a conversão nas bordas do sistema, onde o transporte perde a informação de tipo: corpos de formulário, query strings, variáveis de ambiente, linhas de CSV. Ali tudo chega como texto e alguma coisa precisa convertê-lo.

Entre os seus próprios serviços, onde o JSON já carrega números e booleanos de verdade, a conversão geralmente só esconde bugs. Se um serviço está te enviando "42" onde o contrato diz que deveria ser um número, você quer saber disso.

JunoConvertendo o que chega A conversão usa as próprias regras de conversão do JavaScript, que são mais frouxas do que a palavra sugere. Dois resultados verificados no Zod 4.5.4, e ambos valem a pena lembrar.

z.coerce.number() aceita uma string vazia e retorna 0 com sucesso. Um campo numérico intocado em um formulário envia "", então um valor obrigatório vira zero silenciosamente em vez de falhar na validação. Combine a conversão com uma verificação de intervalo, ou rejeite strings vazias antes de fazer o parse.

z.coerce.boolean() é ainda pior: ele aplica Boolean(), então a string "false" vira true, assim como "0" e qualquer outra coisa não vazia. Quase nunca é o que você quer para um checkbox ou um parâmetro de query. Compare as duas strings que você realmente espera e faça o mapeamento você mesmo.

Coloque a mão na massa

Partindo deste schema e deste objeto:

js
const characterSchema = z.object({
  name: z.string(),
  episode: z.number(),
})

const character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

Faça quatro alterações:

  1. Crie um tipo Character inferido do schema, e anote o objeto com ele.
  2. Adicione uma chave booleana opcional isJedi ao schema.
  3. Faça episode aceitar a string '6' e armazená-la como número.
  4. Registre no console se a validação passou ou falhou, como um único booleano.
Compare suas respostas
ts
type Character = z.infer<typeof characterSchema>

const characterSchema = z.object({
  name: z.string(),
  episode: z.coerce.number(),
  isJedi: z.boolean().optional(),
})

const character: Character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

console.log(characterSchema.safeParse(character).success)
// true

Quatro observações sobre as quatro alterações:

  • z.infer<typeof characterSchema> extrai o tipo diretamente do schema, então adicionar isJedi já atualiza o tipo, sem precisar editar nada de novo.
  • .optional() significa que a chave pode estar ausente, e é por isso que character não precisa ter isJedi e ainda assim passa na validação.
  • z.coerce.number() converte antes de verificar, então a string '6' vira o número 6.
  • .success é o booleano. Registrar apenas safeParse(...) no console imprime o objeto de resultado inteiro, e parse te daria os dados ou uma exceção, e nenhum dos dois é um booleano.

Um detalhe que vale notar: com episode sendo convertido, o tipo anotado diz number, enquanto o objeto literal contém '6'. Essa é a diferença entre as estruturas de entrada e saída, e é por isso que ler result.data em vez do objeto original importa tanto.

Para onde isso vai a seguir

Um safeParse que falha devolve um error, e até aqui isso foi apenas algo cuja existência a gente verificava, não algo que a gente de fato lia.

Lendo erros de validação abre esse assunto: qual campo falhou, o que havia de errado com ele, e como transformar isso em mensagens que uma pessoa consiga usar para agir.