Voltar para Artigos
Back-end6 min de leitura

Validação de Dados no Node.js: Migrando de Joi para Zod

Por que o ecossistema está abandonando o Joi/Celebrate em favor do Zod. Inferência estática de tipos, middleware de validação customizado no Express e formatação de erros padronizada.

12 de agosto de 2026

Regra número um de segurança em Back-end: Nunca confie no input do cliente. Historicamente, usamos Joi com Celebrate no Express para validar payloads. O problema? Você declara o schema de validação no Joi e depois tem que declarar uma interface TypeScript separada para a tipagem, ferindo o princípio DRY (Don't Repeat Yourself). É por isso que o ecossistema migrou para o Zod.

A validação no boundary (fronteira da camada de entrada) tem um objetivo claro: o Use Case e a lógica de negócio nunca deveriam se preocupar com campos ausentes, tipos incorretos ou strings fora do formato esperado. Isso é responsabilidade da camada de infraestrutura mais externa. Se um campo chega ao Use Case, é porque ele foi validado e está no formato correto.

A Vantagem do Zod: Inferência Estática

O Zod foi construído com foco total em TypeScript. Você declara o schema de validação uma única vez e o Zod infere a tipagem estática automaticamente. Isso elimina a necessidade de manter um schema Joi e uma interface TypeScript sincronizados manualmente — um erro clássico que resulta em divergência silenciosa entre o que é validado em runtime e o que o TypeScript acredita que o tipo é.

UserSchema.ts
import { z } from 'zod';

// 1. Declara as regras de validação em runtime
export const CreateUserSchema = z.object({
  name: z.string().min(2, 'Nome muito curto'),
  email: z.string().email('Formato de e-mail inválido'),
  password: z.string().min(8, 'A senha precisa ter no mínimo 8 caracteres'),
  role: z.enum(['USER', 'ADMIN']).default('USER'),
});

// 2. Extrai o tipo estático para uso no resto da aplicação!
// O TypeScript sabe que password é string e role é 'USER' | 'ADMIN'
export type CreateUserDTO = z.infer<typeof CreateUserSchema>;

Middleware de Validação Express + Zod

Como o Zod não é amarrado ao Express (diferente do Celebrate), precisamos de um middleware simples para acoplá-lo às rotas. A vantagem dessa abordagem é que o mesmo schema pode ser usado tanto no middleware do Express quanto para tipar o DTO que entra no Use Case — a fonte de verdade é única.

Outro detalhe importante: o schema.parse() não apenas valida — ele também transforma os dados. Um campo email com .toLowerCase() no schema garante que o valor que chega ao Use Case já está em minúsculas. Um campo z.coerce.number() converte strings numéricas de query params para numbers. Isso elimina a necessidade de transformações manuais em cada controller.

validate.ts
import type { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

export function validate(schema: AnyZodObject) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      // parse() lança erro se falhar.
      // Ele também remove propriedades não mapeadas no schema (strip) 
      // e converte tipos (coerce, ex: '1' -> 1)
      req.body = await schema.parseAsync(req.body);
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        // Formata os erros do Zod para um formato amigável ao Front-end
        return res.status(422).json({
          status: 'error',
          message: 'Erro de validação dos dados',
          details: error.errors.map((e) => ({
            field: e.path.join('.'), // Ex: 'user.address.street'
            message: e.message,
          })),
        });
      }
      next(error);
    }
  };
}

Protegendo a Rota

Com o middleware de validação, a declaração da rota se torna auto-documentada: quem lê o arquivo de rotas sabe imediatamente qual schema é aplicado em qual rota, sem precisar abrir o controller para entender o contrato de entrada esperado.

users.routes.ts
import { Router } from 'express';
import { validate } from '../middlewares/validate';
import { CreateUserSchema } from '../schemas/UserSchema';
import { UsersController } from '../controllers/UsersController';

const usersRouter = Router();
const usersController = new UsersController();

usersRouter.post(
  '/',
  // O request não passa daqui se os dados forem inválidos
  validate(CreateUserSchema),
  usersController.create
);

export { usersRouter };

Você pode estender o middleware validate para aceitar validações simultâneas em diferentes partes do Request: req.body, req.query (para filtros de paginação) e req.params (para garantir que um :id na URL é realmente um UUID antes de consultar o banco de dados).

Conclusão

Manter a validação sintática fora dos Controllers e Use Cases é essencial para o Clean Code. O Use Case deve apenas lidar com regras de negócio (ex: 'Email já cadastrado'), mas nunca com regras estruturais (ex: 'Senha muito curta' ou 'Campo obrigatório ausente'). O Zod eleva isso a outro nível ao unificar a validação de runtime com a tipagem estática do TypeScript.