Validação com Zod no TypeScript: tipos que também funcionam em tempo de execução

Validação com Zod no TypeScript: tipos que também funcionam em tempo de execução

Quem começa com TypeScript costuma achar que, ao declarar o tipo da resposta de uma API, os dados passam a ser conferidos. Não passam. O TypeScript verifica o seu código antes de ele rodar e depois desaparece: o que chega da rede, de um formulário ou de uma variável de ambiente entra no programa sem checagem nenhuma. Este artigo mostra por que isso acontece e como o Zod resolve o problema com um único schema, que valida os dados em tempo de execução e ainda gera o tipo usado pelo editor.

O que o TypeScript faz, e o que não faz

O TypeScript é um verificador estático. Ele analisa o código enquanto você escreve e aponta erros antes da execução. Para rodar, esse código passa por um compilador que gera JavaScript comum, e nessa etapa as anotações de tipo são apagadas. A própria documentação da linguagem avisa que anotações de tipo nunca mudam o comportamento do programa em execução.

Na prática, um type User existe só para o editor e para o compilador. No navegador ou no Node.js ele não existe. Não sobra nada no JavaScript final capaz de olhar um objeto e dizer se ele tem o formato de User.

O problema na prática

Veja uma função comum, que busca um usuário com axios e tipa a resposta com o generic da biblioteca:

import axios from "axios";

type User = {
  id: number;
  name: string;
  email: string;
};

async function getUser(id: number): Promise<User> {
  const { data } = await axios.get<User>(`/api/users/${id}`);
  return data;
}

const user = await getUser(42);
console.log(user.name.toUpperCase());

O axios.get<User> parece uma garantia, mas é só uma promessa que você faz ao compilador. O axios não confere nada. Depois de compilado, o código fica assim:

async function getUser(id) {
  const { data } = await axios.get(`/api/users/${id}`);
  return data;
}

Agora imagine que o backend trocou o campo name por nome, ou que um proxy devolveu uma página de erro em HTML. O compilador não reclamou, porque do ponto de vista dele está tudo certo. O erro só aparece em produção, na linha que usa o dado:

TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Esse ainda é o caso bom, porque ao menos quebra. O caso ruim é um id que chega como a string "42" e segue em silêncio pelo sistema até virar uma comparação que nunca dá verdadeiro.

Instalando o Zod

O Zod é uma biblioteca de validação criada para TypeScript desde o início. A versão atual é a 4, reescrita em 2025 com foco em desempenho. A instalação é um pacote só:

npm install zod

A documentação recomenda o modo estrito do compilador ativado no tsconfig.json, que já é o padrão em projetos novos:

{
  "compilerOptions": {
    "strict": true
  }
}

Um schema, dois papéis

No Zod, você descreve o formato esperado com um schema, que é um objeto JavaScript comum. Por ser JavaScript, ele sobrevive à compilação e está lá quando o código roda.

import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1, "Informe o nome"),
  email: z.email("E-mail inválido"),
});

type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string }

Dessas linhas saem duas coisas. O UserSchema é o validador, que existe em tempo de execução. O User é o tipo, extraído do schema pelo z.infer, e existe só para o compilador. Como os dois vêm da mesma fonte, a validação e o tipo não têm como divergir.

O schema também guarda regras que o tipo não consegue expressar. O TypeScript sabe que email é uma string. O Zod sabe que essa string precisa ter formato de e-mail. Quem vem do Laravel vai reconhecer a ideia: é o papel das regras do $request->validate(), declaradas uma vez e reaproveitadas como tipo.

parse ou safeParse

Com o schema pronto, a função de busca passa a conferir o que recebe:

async function getUser(id: number): Promise<User> {
  const { data } = await axios.get(`/api/users/${id}`);
  return UserSchema.parse(data);
}

O parse devolve o dado já tipado quando ele é válido e lança um ZodError quando não é. Serve para os casos em que um dado inválido deve interromper a operação, como uma rota que não tem o que fazer sem aquele usuário.

Quando a falha faz parte do fluxo normal, o safeParse se encaixa melhor. Ele não lança exceção. Devolve um objeto com a propriedade success, e o TypeScript usa essa propriedade para saber se o que vem depois é o dado ou o erro:

async function findUser(id: number): Promise<User | null> {
  const { data } = await axios.get(`/api/users/${id}`);
  const result = UserSchema.safeParse(data);

  if (!result.success) {
    console.error(z.prettifyError(result.error));
    return null;
  }

  return result.data;
}

Um detalhe útil: por padrão, o z.object remove do resultado as chaves que não foram declaradas no schema. Se a API mandar campos a mais, eles não vazam para o resto do código. Para recusar a resposta nesse caso, troque por z.strictObject.

Mostrando os erros

O erro do Zod traz uma lista de problemas em error.issues, cada um com a mensagem e o caminho do campo. Para log, o z.prettifyError transforma essa lista em texto legível. Com a resposta { id: 42, nome: "Paulo", email: "paulo@" }, a saída fica assim:

✖ Invalid input: expected string, received undefined
  → at name
✖ E-mail inválido
  → at email

Repare que o campo name ausente usou a mensagem padrão, em inglês, porque a mensagem personalizada da regra min(1) só vale quando a string existe. O e-mail usou a mensagem que definimos no schema.

Em formulários, o z.flattenError costuma ser mais prático, porque agrupa as mensagens por campo:

const result = UserSchema.safeParse(formData);

if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error);
  // fieldErrors.email => ["E-mail inválido"]
}

Coerção e transformação

Nem todo dado chega no tipo certo. Query string e variáveis de ambiente são sempre texto, mesmo quando representam números. O z.coerce converte o valor antes de validar:

const EnvSchema = z.object({
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().default(3000),
});

export const env = EnvSchema.parse(process.env);
// env.PORT é number, não string

Validar o process.env na inicialização faz a aplicação falhar logo ao subir, com uma mensagem clara, em vez de quebrar horas depois quando alguém acessar a rota que depende da variável esquecida.

O transform vai além e muda o formato do dado. Datas em JSON chegam como texto. Com uma transformação, o resto do código já recebe um Date:

const OrderSchema = z.object({
  id: z.number(),
  createdAt: z.iso.datetime().transform((value) => new Date(value)),
});

type Order = z.infer<typeof OrderSchema>;
// { id: number; createdAt: Date }

Aqui o tipo de entrada e o de saída são diferentes, e o Zod acompanha a diferença. O z.infer devolve o tipo de saída, com createdAt: Date. Se você precisar do formato bruto, existe o z.input.

Onde validar

Não é preciso passar todo objeto do sistema por um schema. Dentro do seu código, onde os dados foram criados por você, o TypeScript dá conta. A validação vale nas fronteiras, nos pontos em que um dado entra no programa vindo de fora:

  • respostas de APIs, inclusive a do seu próprio backend;
  • corpo das requisições que o seu backend recebe;
  • variáveis de ambiente;
  • formulários;
  • conteúdo lido de localStorage, arquivos, webhooks e filas.

Uma regra simples ajuda: tudo o que chega como unknown ou any deveria passar por um schema antes de ganhar um tipo. Depois dessa porta, o tipo inferido segue pelo sistema e o compilador volta a fazer o trabalho dele.

A validação tem custo de processamento, mas ele é pequeno. Segundo as notas de lançamento, o parse de objetos ficou cerca de 6,5 vezes mais rápido na versão 4 do que na 3. Na 4.5, falhas no safeParse ficaram por volta de 7,5 vezes mais rápidas, porque a biblioteca deixou de capturar o stack trace nesse caso.

Referências

  • COPES, Flavio. How to use Zod 4 for TypeScript validation. Flavio Copes, 2026. Disponível em: https://flaviocopes.com/zod/. Acesso em: 21 set. 2026.
  • MICROSOFT. The Basics. TypeScript Handbook, 2026. Disponível em: https://www.typescriptlang.org/docs/handbook/2/basic-types.html. Acesso em: 21 set. 2026.
  • ZOD. Basic usage. Zod, [2026]. Disponível em: https://zod.dev/basics. Acesso em: 21 set. 2026.
  • ZOD. Formatting errors. Zod, [2026]. Disponível em: https://zod.dev/error-formatting. Acesso em: 21 set. 2026.
  • ZOD. Release notes: Zod 4. Zod, 2025. Disponível em: https://zod.dev/v4. Acesso em: 21 set. 2026.
  • ZOD. Zod 4.5. Zod, 2026. Disponível em: https://zod.dev/blog/zod-4-5. Acesso em: 21 set. 2026.

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo