Validação com Zod no TypeScript: tipos que também funcionam em tempo de execução
Paulo Roberto Bolsanello·21 Set 2026·8 min de leitura
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 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.
Ao criar um Pull Request, é comum perceber depois que um arquivo foi modificado por engano ou não deveria estar ali. Em vez de remover completamente o arquivo do repositório (usando git rm), muitas vezes precisamos…
No ecossistema Laravel, há duas formas principais de interagir com o banco de dados: o Eloquent ORM e o Query Builder. Ambos têm seu espaço, mas entender quando usar cada um é essencial para escrever código limpo,…