Validação de dados no backend com Zod: prático e tipado
← Voltar para Codeshort

Validação de dados no backend com Zod: prático e tipado

Pare de validar dados no chute. Zod resolve validação e tipagem ao mesmo tempo, com zero overhead de configuração.

DC
Dev Code Software
24 de junho de 2026·5 min de leitura

Por que validação no backend ainda é problema

Você já recebeu um undefined onde esperava uma string? Ou um número vindo como texto de um formulário e só descobriu no banco quando a query falhou? Esse tipo de bug aparece justamente quando você não validou a entrada.

A maioria dos devs faz validação assim:

if (!req.body.email || typeof req.body.email !== "string") {
  return res.status(400).json({ error: "Email inválido" });
}

Funciona. Mas não escala. Com dez campos num endpoint de cadastro, você tem quarenta linhas de if antes de chegar na lógica real. E ainda assim, o TypeScript não sabe que req.body.email é uma string depois desse check — você segue no any.

O que o Zod resolve que outros não resolvem

Tem outras libs de validação: Joi, Yup, class-validator. O Zod se diferencia num ponto que importa: o schema é a fonte de verdade para o tipo TypeScript também.

Com Yup, você define o schema e ainda precisa escrever a interface separada. Com Zod:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});

type User = z.infer<typeof UserSchema>;

User agora é { name: string; email: string; age?: number } — inferido automaticamente. Sem duplicação, sem divergência entre schema e tipo.

💡 Dica: z.infer é o recurso mais subestimado do Zod. Usar ele elimina a necessidade de manter tipos e schemas em sincronia manualmente — algo que inevitavelmente sai do controle em projetos maiores.

Instalação e primeiros schemas

npm install zod

Sem peer dependencies, sem configuração extra. Só importar e usar.

Tipos primitivos básicos:

const StringSchema = z.string();
const NumberSchema = z.number();
const BoolSchema = z.boolean();
const DateSchema = z.date();
const UUIDSchema = z.string().uuid();

Objetos com validações encadeadas:

const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(3).max(100),
  price: z.number().positive(),
  category: z.enum(["electronics", "clothing", "food"]),
  tags: z.array(z.string()).optional().default([]),
  createdAt: z.date().default(() => new Date()),
});

.default() é especialmente útil: define valor padrão e já reflete no tipo inferido — o campo vira não-opcional para quem usa o resultado do parse.

Validando requisições HTTP na prática

Em Express, o req.body é any. Com Zod, você transforma isso num tipo seguro antes de tocar na lógica de negócio:

import { Request, Response } from "express";
import { z } from "zod";

const CreateUserSchema = z.object({
  name: z.string().min(2, "Nome precisa ter ao menos 2 caracteres"),
  email: z.string().email("Email inválido"),
  password: z.string().min(8, "Senha precisa ter ao menos 8 caracteres"),
  role: z.enum(["admin", "user"]).default("user"),
});

type CreateUserDTO = z.infer<typeof CreateUserSchema>;

async function createUser(req: Request, res: Response) {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: "Dados inválidos",
      details: result.error.flatten().fieldErrors,
    });
  }

  const data: CreateUserDTO = result.data;
  // Aqui data é tipado. Sem any. Sem casting manual.

  // ... lógica de criação
}

A diferença entre .parse() e .safeParse() é simples: parse lança exceção se falhar, safeParse retorna { success: false, error } ou { success: true, data }. Em handlers HTTP, safeParse é o caminho certo — você controla a resposta sem precisar de try/catch em volta.

Isso apareceu num PR meu há um tempo e o comentário do revisor foi objetivo: "por que você tá fazendo cast manual se o Zod já inferiu o tipo?". Tinha razão. Eu ainda estava fazendo const data = result.data as CreateUserDTO por hábito.

Erros de validação com mensagens úteis

Por padrão, os erros do Zod são detalhados mas no formato interno. Para retornar ao cliente:

result.error.flatten().fieldErrors

Isso entrega um objeto onde cada chave é o campo e o valor é um array de mensagens:

{
  "email": ["Email inválido"],
  "password": ["Senha precisa ter ao menos 8 caracteres"]
}

Se quiser uma lista plana de todos os erros:

result.error.flatten().formErrors

Para middlewares de erro globais, você pode checar se o erro é de Zod:

import { ZodError } from "zod";

app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof ZodError) {
    return res.status(400).json({
      error: "Validação falhou",
      details: err.flatten().fieldErrors,
    });
  }
  // ... outros erros
});

Aí nos handlers você usa .parse() e deixa o middleware cuidar do catch — menos boilerplate por rota.

Schemas reutilizáveis e composição

Zod foi projetado para composição. Algumas operações que salvam tempo em projetos reais:

Extensão de schemas:

const BaseEntitySchema = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
  updatedAt: z.date(),
});

const ProductSchema = BaseEntitySchema.extend({
  name: z.string(),
  price: z.number().positive(),
});

Omitir ou selecionar campos — útil para DTOs de criação/atualização:

const CreateProductSchema = ProductSchema.omit({ id: true, createdAt: true, updatedAt: true });
const UpdateProductSchema = ProductSchema.partial().omit({ id: true });

partial() torna todos os campos opcionais. Combinar com omit dá exatamente o shape de um PATCH endpoint sem escrever o tipo do zero.

Union para endpoints que aceitam formatos diferentes:

const PaymentSchema = z.union([
  z.object({ method: z.literal("credit_card"), cardToken: z.string() }),
  z.object({ method: z.literal("pix"), pixKey: z.string() }),
]);

TypeScript infere um union type discriminado — e o autocomplete funciona depois de checar method.

Armadilhas comuns com Zod

Dados vindos de formulário HTML chegam como string

<input type="number"> envia "42", não 42. O Zod por padrão recusa isso.

const Schema = z.object({
  price: z.coerce.number().positive(),
});

z.coerce.number() faz a conversão. Use com consciência — num body JSON você não quer coerção implícita, mas em query params e form data é quase sempre necessário.

Campos desconhecidos

Por padrão, o Zod remove campos extras do objeto parseado (strip mode). Se você quer rejeitar objetos com campos desconhecidos:

const StrictSchema = z.object({ name: z.string() }).strict();

Se quer manter os campos extras sem validar:

const PassthroughSchema = z.object({ name: z.string() }).passthrough();

⚠️ Atenção: Não use .passthrough() em dados de entrada do usuário se esses dados vão direto para o banco. Você vai persistir campos que nunca deveriam ser persistidos.

Validação assíncrona

Se você precisar validar algo que depende de uma consulta ao banco (email único, por exemplo), use .refine() com async:

const schema = z.object({
  email: z.string().email(),
}).refine(
  async (data) => {
    const exists = await db.user.findUnique({ where: { email: data.email } });
    return !exists;
  },
  { message: "Email já cadastrado", path: ["email"] }
);

const result = await schema.safeParseAsync(req.body);

safeParseAsync — não esqueça o Async aqui. .safeParse() não aguarda promises.

FAQ

Zod funciona no frontend também? Sim. O bundle é pequeno (~14kb minificado) e roda no browser sem adaptação. É comum compartilhar schemas entre frontend e backend num monorepo — você valida no cliente antes de enviar e no servidor antes de processar, com o mesmo código.

Qual a diferença entre Zod e class-validator? Class-validator usa decorators em classes, o que obriga a instanciar objetos e funciona melhor com NestJS. Zod é funcional, sem decorators, e se encaixa melhor em projetos Express/Fastify/Hono ou qualquer estrutura que não gire em torno de classes. Para projetos novos com TypeScript puro, Zod tem menos fricção.

Preciso reescrever meus tipos existentes para usar Zod? Não necessariamente. Você pode começar pela borda — validando apenas o que entra no sistema (req.body, query params, env vars) — sem tocar nos tipos internos. A migração pode ser incremental.

Consigo validar variáveis de ambiente com Zod? Essa é uma das melhores aplicações. Crie um schema para seu .env, parse no boot da aplicação e trave se algo estiver faltando. Sem segredos silenciosamente undefined em produção.

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

export const env = EnvSchema.parse(process.env);

E performance? Zod é lento? Não para o caso de uso comum. Validação de objetos simples é imperceptível. Em casos extremos de altíssimo throughput com payloads gigantes, existem alternativas mais rápidas como Typebox ou Valibot — mas para 99% dos projetos, o Zod é rápido o suficiente.

Próximos passos

Se você ainda não usa Zod no dia a dia, começa pelo mais simples: valide seu .env. É um caso de uso isolado, sem refatorar nada, e você vê o valor imediatamente.

Depois disso:

  • Adicione validação nos endpoints que mais recebem dados externos (cadastro, login, webhooks)
  • Experimente z.infer para eliminar interfaces que duplicam schemas existentes
  • Explore z.discriminatedUnion para payloads com formatos variantes — é mais eficiente que z.union quando há um campo discriminador

A documentação oficial em zod.dev é excelente e cobre casos avançados como transformações, pipes e schemas recursivos. Vale uma leitura quando você já estiver confortável com o básico.