Validar dados em runtime no TypeScript com Zod
1. Visão geral
Os tipos do TypeScript são removidos após a compilação. Portanto, eles não protegem você contra JSON malformado vindo de APIs, dados de formulários enviados pelo usuário ou variáveis de ambiente com valores ausentes.
Este artigo mostra como usar Zod 4 para validar dados em runtime. Você declara o schema uma única vez e infere o tipo dele. Você também aprenderá como lidar com erros de forma limpa, escrever testes e evitar algumas armadilhas comuns.
2. Por que tipos TypeScript não são suficientes em runtime
Considere o seguinte trecho de código familiar:
interface Customer {
id: number;
email: string;
}
const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;
console.log(customer.id + 1);
console.log(customer.email.toLowerCase());
JSON.parse retorna any. O comando as Customer é apenas uma promessa ao compilador, sem nenhuma verificação real. Resultado da execução:
421
TypeError: Cannot read properties of null (reading 'toLowerCase')
id é a string "42", então a adição resulta em concatenação. email é null, então o programa falha. O compilador não relata nada. Você precisa de uma camada de verificação real em runtime. Zod faz exatamente isso.
3. Instalação
Zod não tem outras dependências e funciona tanto em Node.js quanto em navegadores:
npm install zod
Para que Zod infira tipos corretamente, você precisa ativar strict mode no tsconfig.json:
{
"compilerOptions": {
"strict": true
}
}
Nota: Sem
"strict": true, campos opcionais podem ser inferidos incorretamente. Você perderá a maioria dos benefícios de tipo.
4. Definindo schema e inferindo tipos
O schema descreve a forma dos dados que você espera:
import { z } from "zod";
const CustomerSchema = z.object({
id: z.number().int().positive(),
email: z.email(),
fullName: z.string().min(2).max(100),
phone: z.string().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});
type Customer = z.infer<typeof CustomerSchema>;
Cada parte funciona da seguinte forma:
z.number().int().positive()exige um número inteiro positivo.z.email()valida se a string tem formato de email válido. No Zod 4, formatos de string como email, URL, UUID são declarados no nível superior..optional()permite que o campo seja ausente ou tenha o valorundefined..default("FREE")preenche automaticamente o valor quando o input não tem o campotier.z.inferinfere o tipo TypeScript do schema.
Quando você passa o mouse sobre Customer, você vê o tipo equivalente:
type Customer = {
id: number;
email: string;
fullName: string;
phone?: string | undefined;
tier: "FREE" | "PRO" | "ENTERPRISE";
}
A força está aqui: o schema é a única fonte de verdade. Quando você adiciona um campo ao schema, o tipo é atualizado automaticamente. Você não precisa manter uma interface separada.
5. parse e safeParse
Zod tem duas formas principais de executar validação.
5.1. parse: lança exceção quando inválido
const customer = CustomerSchema.parse({
id: 42,
email: "an.nguyen@example.com",
fullName: "Nguyễn Văn An",
});
console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }
O valor retornado foi validado e tem tipo Customer. O campo tier foi preenchido com o padrão. Se os dados forem inválidos, parse lança ZodError.
5.2. safeParse: retorna resultado, não lança erros
const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]
result é uma union discriminada. Depois que você verifica result.success, TypeScript sabe com certeza qual branch tem data e qual tem error. Você também vê que Zod não para no primeiro erro, mas coleta todos os erros. Isso é muito útil ao exibir erros em formulários.
A regra de escolha é simples:
- Use
safeParsena fronteira do sistema, por exemplo, corpo da solicitação ou formulário, onde erros são normais. - Use
parsequando dados inválidos significam um bug ou configuração quebrada e o aplicativo deve parar imediatamente.
6. Mensagens de erro personalizadas e formatação de erro
As mensagens de erro padrão do Zod são em inglês e bastante técnicas. Você pode passar um parâmetro error para substituí-las:
const SignUpSchema = z.object({
email: z.email({ error: "Email não é válido" }),
password: z.string().min(8, { error: "Senha deve ter no mínimo 8 caracteres" }),
});
const result = SignUpSchema.safeParse({ email: "abc", password: "123" });
if (!result.success) {
console.log(z.flattenError(result.error));
}
{
formErrors: [],
fieldErrors: {
email: [ 'Email não é válido' ],
password: [ 'Senha deve ter no mínimo 8 caracteres' ]
}
}
z.flattenError agrupa erros por nome de campo. Este formato é bom para retornar para o frontend. Quando você precisa fazer log para leitura humana, use z.prettifyError(result.error) para obter uma string de múltiplas linhas fácil de ler.
7. Coerce, transform e refine
Dados do mundo real raramente combinam perfeitamente com o schema. Zod tem três ferramentas para lidar com essa situação.
7.1. z.coerce: força conversão de tipo da entrada
Query strings e variáveis de ambiente são sempre strings. z.coerce as converte antes de validar:
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().max(100).default(20),
});
console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }
Nota:
z.coerce.number()chamaNumber(input). Portanto, string vazia""vira0e passa na verificação de tipo. Sempre adicione restrições como.min(1)para bloquear isso.
7.2. transform: transforma depois de validar
const OrderCodeSchema = z
.string()
.trim()
.regex(/^ord-\d{6}$/i, { error: "Código do pedido não está no formato correto" })
.transform((code) => code.toUpperCase());
console.log(OrderCodeSchema.parse(" ord-000123 "));
ORD-000123
Depois que você tem transform, o tipo de entrada e saída podem ser diferentes. Use z.input<typeof S> e z.output<typeof S> para obter cada um separadamente. z.infer é equivalente a z.output.
7.3. refine: regras de negócio arbitrárias
Existem restrições que envolvem vários campos ao mesmo tempo. Você coloca refine no nível do objeto para lidar com isso:
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
})
.refine((b) => b.checkOut > b.checkIn, {
error: "Data de saída deve ser após a data de entrada",
path: ["checkOut"],
});
const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] Data de saída deve ser após a data de entrada
path vincula o erro ao campo checkOut em vez de ao objeto inteiro. Isso permite que o frontend exiba o erro no lugar correto.
8. Exemplos práticos
8.1. Validar requisição de criação de pedido
O exemplo abaixo é uma função que processa requisições, independente de framework. Você pode usá-la com Express, Fastify ou Next.js:
import { z } from "zod";
const OrderItemSchema = z.object({
productId: z.uuid(),
quantity: z.number().int().min(1).max(99),
});
export const CreateOrderSchema = z.object({
customerId: z.number().int().positive(),
items: z.array(OrderItemSchema).nonempty({ error: "Pedido deve ter pelo menos 1 produto" }),
note: z.string().max(500).optional(),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
export function handleCreateOrder(body: unknown) {
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return { status: 400, body: z.flattenError(result.error).fieldErrors };
}
const order: CreateOrderRequest = result.data;
return { status: 201, body: { itemCount: order.items.length } };
}
Note que o parâmetro body tem tipo unknown, não any. Isso força TypeScript a validar antes de usar. Depois de safeParse, result.data tem tipo completo. Por padrão, z.object também remove campos desconhecidos, então o cliente não pode “injetar” isAdmin: true no seu objeto.
8.2. Validar variáveis de ambiente na inicialização
O aplicativo deve parar imediatamente se a configuração for inválida, não espere até processar a primeira requisição:
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env);
Suponha que DATABASE_URL está faltando. O aplicativo lançará ZodError imediatamente na importação do módulo, com uma mensagem indicando que DATABASE_URL está ausente. Em qualquer lugar do código, env.PORT tem tipo number, não string | undefined.
9. Escrevendo testes com Vitest
Como o schema contém regras de negócio, também precisa de testes. Primeiro, instale Vitest:
npm install -D vitest
Depois escreva testes para CreateOrderSchema:
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";
describe("CreateOrderSchema", () => {
const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };
it("aceita pedido válido e remove campos desconhecidos", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [validItem],
isAdmin: true,
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ customerId: 7, items: [validItem] });
});
it("rejeita pedido sem produtos", () => {
const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });
expect(result.success).toBe(false);
expect(z.flattenError(result.error!).fieldErrors.items).toContain(
"Pedido deve ter pelo menos 1 produto",
);
});
it("rejeita quantidade acima do limite", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [{ ...validItem, quantity: 100 }],
});
expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
});
});
Execute npx vitest run e você verá que todos os três testes passam. O terceiro teste mostra que o path do erro vai até o elemento específico no array. Esta é exatamente a informação que o frontend precisa para destacar o campo de entrada correto.
10. Erros comuns e quando não usar
Forçar tipo com as depois de ter schema. Escrever body as CreateOrderRequest quebra todo o mecanismo de proteção. Sempre obtenha dados de result.data.
Usar refine assíncrono com parse. Por exemplo, você verifica se o email já existe no banco de dados. Quando há refine async, você deve chamar parseAsync ou safeParseAsync. Caso contrário, Zod lançará um erro.
Esperar que campos desconhecidos sejam rejeitados. z.object silenciosamente descarta campos desconhecidos por padrão. Se você quiser relatar erro quando há campos extras, por exemplo, para capturar erros de digitação em arquivos de configuração, use z.strictObject(...).
Validar repetidamente dentro do aplicativo. Você só deve validar na fronteira do sistema: requisições, respostas de APIs de terceiros, filas de mensagens, arquivos de configuração. Confie no tipo de dados já validados. Parse novamente em cada função apenas desperdiça CPU.
Nota: Não use Zod em loops críticos, onde você precisa processar centenas de milhares de objetos por segundo. Validação tem custo real. Nessas situações, valide uma vez na entrada ou escolha uma biblioteca que compila schema em código.
Declarar schema dentro de funções. Se você criar o schema toda vez que a função é chamada, o aplicativo desperdiça CPU com inicialização. Declare o schema no nível do módulo e reutilize.
11. Conclusão
Zod preenche a lacuna entre tipos em tempo de compilação e dados reais em runtime. Você declara o schema uma vez, infere o tipo com z.infer, e então usa safeParse na fronteira do sistema para receber dados validados ou uma lista estruturada de erros.
Com coerce, transform e refine, você lida com a maioria das situações do mundo real: query strings, normalização de dados, regras de negócio envolvendo múltiplos campos. Valide cedo, valide na borda, e deixe TypeScript cuidar do resto.