Como usar o Zod para validar e tipar payloads de entrada em APIs

1. Introdução ao Zod e seu papel na validação de APIs

Em APIs modernas, validar payloads de entrada não é apenas uma boa prática — é uma necessidade crítica de segurança e confiabilidade. Dados malformados podem causar desde erros silenciosos até vulnerabilidades graves como injeção de dados ou quebra de regras de negócio.

Zod é uma biblioteca de validação de esquemas baseada em TypeScript que se destaca por sua simplicidade, poder expressivo e integração profunda com o sistema de tipos da linguagem. Diferente de abordagens manuais (que exigem dezenas de if aninhados) ou bibliotecas tradicionais como Joi e Yup, o Zod oferece inferência automática de tipos TypeScript, validações encadeadas e refinamentos customizados em uma API coesa e declarativa.

2. Configuração inicial e primeiros esquemas com Zod

Para começar, instale o Zod em seu projeto Node.js/TypeScript:

npm install zod

Criar seu primeiro esquema é direto. Vamos validar um payload típico de criação de usuário:

import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(3, 'Nome deve ter no mínimo 3 caracteres'),
  email: z.string().email('Email inválido'),
  age: z.number().int().positive('Idade deve ser positiva'),
  isActive: z.boolean().default(true)
});

Zod oferece tipos nativos com validações encadeadas: z.string().min().max().email(), z.number().int().positive(), z.boolean(). O método .default() define valores padrão caso o campo seja omitido.

3. Validação avançada de campos: refinamentos e transformações

Para regras de negócio complexas, use .refine(). Por exemplo, validar que a senha contém pelo menos um número:

const passwordSchema = z.string()
  .min(8, 'Senha deve ter no mínimo 8 caracteres')
  .refine(val => /\d/.test(val), 'Senha deve conter pelo menos um número');

Transformações com .transform() permitem normalizar dados antes da validação final:

const emailSchema = z.string()
  .email()
  .transform(val => val.toLowerCase().trim());

const cpfSchema = z.string()
  .regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'CPF inválido')
  .transform(val => val.replace(/\D/g, ''));

Validações de padrão com .regex(), .email(), .url() e .uuid() cobrem casos comuns sem necessidade de expressões regulares manuais.

4. Esquemas aninhados, arrays e unions para payloads complexos

Payloads de API frequentemente contêm estruturas aninhadas. Zod lida com isso naturalmente:

const orderSchema = z.object({
  id: z.string().uuid(),
  customer: z.object({
    name: z.string(),
    address: z.object({
      street: z.string(),
      city: z.string(),
      zipCode: z.string().regex(/^\d{5}-\d{3}$/)
    })
  }),
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive(),
    price: z.number().positive()
  })).min(1, 'Pedido deve ter ao menos um item'),
  status: z.enum(['pending', 'shipped', 'delivered', 'cancelled'])
});

Para payloads polimórficos, unions e discriminated unions são essenciais:

const eventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('user_created'), userId: z.string() }),
  z.object({ type: z.literal('order_placed'), orderId: z.string(), amount: z.number() }),
  z.object({ type: z.literal('payment_failed'), error: z.string() })
]);

5. Integração com frameworks de API (Express, Fastify, Hono)

Express — middleware de validação simples:

import { Request, Response, NextFunction } from 'express';

function validate(schema: z.ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        res.status(400).json({ errors: error.errors });
      } else {
        next(error);
      }
    }
  };
}

// Uso na rota
app.post('/users', validate(createUserSchema), (req, res) => {
  // req.body já está validado e tipado
});

Fastify — suporte nativo a esquemas Zod via plugin:

import fastify from 'fastify';
import { serializerCompiler, validatorCompiler } from 'fastify-zod';

const app = fastify();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.post('/users', {
  schema: {
    body: createUserSchema
  }
}, async (request, reply) => {
  // request.body está validado e tipado automaticamente
});

Hono (Cloudflare Workers) — validação direta no handler:

import { zValidator } from '@hono/zod-validator';

app.post('/users', zValidator('json', createUserSchema), (c) => {
  const userData = c.req.valid('json');
  // userData tem tipo inferido automaticamente
});

6. Tipagem inferida e segurança de tipos no runtime

O poder real do Zod está na inferência de tipos TypeScript com z.infer:

type CreateUserInput = z.infer<typeof createUserSchema>;
// TypeScript infere: { name: string; email: string; age: number; isActive: boolean }

Isso elimina a necessidade de interfaces duplicadas. O tipo inferido corresponde exatamente à validação — se você adicionar .email(), o tipo continua sendo string, mas o runtime garante que só emails válidos passem. Em controllers, evite any:

async function createUser(data: unknown) {
  const validated = createUserSchema.parse(data);
  // validated tem tipo CreateUserInput
  return await db.user.create({ data: validated });
}

7. Tratamento de erros e mensagens personalizadas

z.parse() lança ZodError se a validação falhar. Para tratamento mais suave, use z.safeParse():

const result = createUserSchema.safeParse(payload);

if (!result.success) {
  console.error(result.error.errors);
  // Array de objetos com path, message, code
}

// Para uso em API:
if (!result.success) {
  return res.status(400).json({
    type: 'https://example.com/validation-error',
    title: 'Erro de validação',
    status: 400,
    errors: result.error.errors.map(e => ({
      field: e.path.join('.'),
      message: e.message
    }))
  });
}

Mensagens personalizadas por campo melhoram a experiência do desenvolvedor:

const schema = z.object({
  email: z.string().email('Forneça um email corporativo válido'),
  age: z.number().int('Idade deve ser número inteiro').positive('Idade deve ser positiva')
});

8. Boas práticas e armadilhas comuns

Separação de responsabilidades: mantenha esquemas em arquivos separados (schemas/user.ts) e importe-os nos controllers. Isso facilita testes e reuso.

Evite validação duplicada: se o frontend já valida com Zod (via bibliotecas como @hookform/resolvers), compartilhe o mesmo esquema usando um pacote compartilhado (shared/schemas).

Performance: use safeParse quando a validação é opcional ou você precisa de controle de fluxo. Use parse para cenários onde a falha deve interromper imediatamente. Cache esquemas complexos em variáveis — Zod já é otimizado, mas evitar recriação desnecessária ajuda.

Armadilha comum: não confie apenas na tipagem TypeScript para segurança. Zod valida em runtime, o que é crucial porque dados de entrada (API, formulários) nunca têm tipos garantidos.

Referências