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.
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 é.
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.
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.
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.