Por que validação em runtime é obrigatória (mesmo com TypeScript)
TypeScript protege o código em tempo de compilação, mas não garante que o que chega pela rede esteja no formato correto. Em uma API Express, req.body, req.params e req.query são dados externos e potencialmente maliciosos. Por isso, a validação precisa acontecer em runtime, antes de qualquer regra de negócio.
Uma estratégia sólida costuma separar três responsabilidades:
- DTO (contrato): descreve o formato esperado (e serve como tipo no código).
- Schema (validação): valida em runtime e gera mensagens de erro consistentes.
- Parsing/transformação: coerção e normalização (ex.: string para número, trim, defaults), sem confundir com validação.
Estratégia recomendada: validar body, params e query com schemas
Objetivos práticos
- Validar body, params e query de forma explícita.
- Padronizar erros (mesma estrutura para qualquer rota).
- Separar validação de transformação (coerção).
- Refletir o resultado em tipos TypeScript para o controller trabalhar com dados confiáveis.
Biblioteca de schema
Um caminho comum em projetos TypeScript é usar uma biblioteca que faça validação em runtime e inferência de tipos. Nos exemplos abaixo, será usado zod por ser simples e oferecer boa integração com TypeScript.
npm i zodPadronizando erros de validação
Antes de criar schemas, defina um formato único de resposta para erros. Isso facilita o consumo por front-ends e integrações.
Formato sugerido
type ApiErrorResponse = { error: { code: string; message: string; details?: Array<{ path: string; message: string }>; }};Exemplo de resposta:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid request data", "details": [ { "path": "body.email", "message": "Invalid email" }, { "path": "query.page", "message": "Expected number, received nan" } ] }}Função para mapear erros do Zod
import { ZodError } from "zod";export function formatZodError(err: ZodError) { return err.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, }));}Para manter consistência, prefixe o caminho com a origem (body/query/params) no middleware (veremos adiante).
DTOs vs Schemas: como organizar
DTO como tipo derivado do schema (evita divergência)
Em vez de escrever um interface manual e um schema separado (que podem divergir), prefira derivar o tipo do schema:
import { z } from "zod";export const CreateUserBodySchema = z.object({ name: z.string().min(2, "Name must have at least 2 characters").max(80), email: z.string().email("Invalid email"), password: z.string().min(8, "Password must be at least 8 characters").max(72),});export type CreateUserBodyDTO = z.infer<typeof CreateUserBodySchema>;Assim, o controller usa CreateUserBodyDTO e tem garantia de que o dado foi validado conforme o schema.
Separando validação de parsing/transformação (coerção)
Um erro comum é misturar validação com coerção de tipos sem clareza. Exemplo: req.query.page chega como string, mas você quer número.
Opção A: coerção explícita no schema (transformação controlada)
Use coerção quando a API aceitar múltiplas representações de entrada (ex.: "1" e 1), mas ainda quiser validar o resultado.
import { z } from "zod";export const ListUsersQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), search: z.string().trim().min(1).max(100).optional(),});export type ListUsersQueryDTO = z.infer<typeof ListUsersQuerySchema>;Aqui, page e pageSize são coeridos para número e validados. O controller recebe números de verdade.
Opção B: parsing antes do schema (quando a coerção precisa de regras próprias)
Quando a transformação é mais complexa (ex.: normalizar datas, aceitar formatos diferentes, mapear enums), você pode criar uma etapa de parsing que produz um objeto intermediário e então validar. Isso evita esconder regras de negócio dentro do schema.
function parseListUsersQuery(raw: any) { return { ...raw, search: typeof raw.search === "string" ? raw.search.trim() : raw.search, };}const parsed = parseListUsersQuery(req.query);const query = ListUsersQuerySchema.parse(parsed);Regra prática: coerção simples (string->número, trim, default) pode ficar no schema; transformações com regras específicas podem ficar em um parser dedicado.
Implementando uma camada de validação antes dos controllers
A ideia é criar um middleware reutilizável que:
- Recebe schemas para
body,paramsequery. - Valida e transforma os dados.
- Em caso de erro, responde com formato padronizado.
- Em caso de sucesso, disponibiliza os dados validados para o controller.
1) Tipando o Request para carregar dados validados
Evite depender de req.body diretamente no controller. Uma abordagem simples é anexar um objeto validated ao request.
import type { Request } from "express";export type ValidatedRequest<B, P, Q> = Request & { validated: { body: B; params: P; query: Q; };};Você pode usar generics no controller para ter tipos fortes.
2) Middleware genérico validate()
import type { Request, Response, NextFunction } from "express";import { z, ZodError } from "zod";import { formatZodError } from "./formatZodError";type Schemas = { body?: z.ZodTypeAny; params?: z.ZodTypeAny; query?: z.ZodTypeAny;};export function validate(schemas: Schemas) { return (req: Request, res: Response, next: NextFunction) => { try { const validatedBody = schemas.body ? schemas.body.parse(req.body) : req.body; const validatedParams = schemas.params ? schemas.params.parse(req.params) : req.params; const validatedQuery = schemas.query ? schemas.query.parse(req.query) : req.query; (req as any).validated = { body: validatedBody, params: validatedParams, query: validatedQuery, }; next(); } catch (e) { if (e instanceof ZodError) { const details = formatZodError(e).map((d) => ({ path: d.path, message: d.message, })); return res.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Invalid request data", details, }, }); } next(e); } };}Observação: para deixar o path mais informativo, você pode rodar validações separadas e prefixar body., query., params. em cada erro. Uma forma prática é usar safeParse em cada parte e acumular issues.
3) Versão com acúmulo de erros e prefixo por origem
import type { Request, Response, NextFunction } from "express";import { z } from "zod";type Schemas = { body?: z.ZodTypeAny; params?: z.ZodTypeAny; query?: z.ZodTypeAny;};export function validateAll(schemas: Schemas) { return (req: Request, res: Response, next: NextFunction) => { const details: Array<{ path: string; message: string }> = []; const out: any = { body: req.body, params: req.params, query: req.query }; ( [ ["body", schemas.body, req.body], ["params", schemas.params, req.params], ["query", schemas.query, req.query], ] as const).forEach(([key, schema, value]) => { if (!schema) return; const result = schema.safeParse(value); if (!result.success) { result.error.issues.forEach((issue) => { details.push({ path: `${key}.${issue.path.join(".")}`, message: issue.message, }); }); } else { out[key] = result.data; } }); if (details.length > 0) { return res.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Invalid request data", details, }, }); } (req as any).validated = out; next(); };}Essa versão retorna todos os erros de uma vez, com caminhos consistentes.
Exemplo completo: rota com body + params + query
Schemas
import { z } from "zod";export const UserIdParamsSchema = z.object({ userId: z.string().uuid("userId must be a valid UUID"),});export type UserIdParamsDTO = z.infer<typeof UserIdParamsSchema>;export const UpdateUserBodySchema = z.object({ name: z.string().min(2).max(80).optional(), email: z.string().email().optional(),}).refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided",});export type UpdateUserBodyDTO = z.infer<typeof UpdateUserBodySchema>;export const UpdateUserQuerySchema = z.object({ notify: z.coerce.boolean().default(false),});export type UpdateUserQueryDTO = z.infer<typeof UpdateUserQuerySchema>;Rota e controller usando req.validated
import { Router, Response } from "express";import type { ValidatedRequest } from "./ValidatedRequest";import { validateAll } from "./validateAll";import { UserIdParamsSchema, UpdateUserBodySchema, UpdateUserQuerySchema } from "./schemas";const router = Router();router.patch( "/users/:userId", validateAll({ params: UserIdParamsSchema, body: UpdateUserBodySchema, query: UpdateUserQuerySchema, }), async (req: ValidatedRequest< import("./schemas").UpdateUserBodyDTO, import("./schemas").UserIdParamsDTO, import("./schemas").UpdateUserQueryDTO >, res: Response) => { const { userId } = req.validated.params; const patch = req.validated.body; const { notify } = req.validated.query; // regra de negócio aqui (ex.: atualizar no banco) return res.status(200).json({ userId, patch, notify }); });export default router;O controller não precisa revalidar nem fazer casts: os dados já chegam coeridos e tipados.
Payloads inválidos e respostas padronizadas
Exemplo 1: params inválido
Requisição:
PATCH /users/123?notify=trueContent-Type: application/json{ "name": "Ana" }Resposta (400):
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid request data", "details": [ { "path": "params.userId", "message": "userId must be a valid UUID" } ] }}Exemplo 2: body vazio (falha de refine)
Requisição:
PATCH /users/550e8400-e29b-41d4-a716-446655440000Content-Type: application/json{}Resposta (400):
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid request data", "details": [ { "path": "body", "message": "At least one field must be provided" } ] }}Exemplo 3: query com valor não coerível
Requisição:
PATCH /users/550e8400-e29b-41d4-a716-446655440000?notify=maybeContent-Type: application/json{ "email": "ana@example.com" }Resposta (400):
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid request data", "details": [ { "path": "query.notify", "message": "Expected boolean, received string" } ] }}Se você quiser aceitar notify=1/0 ou yes/no, implemente um parser específico antes do schema (separando transformação de validação).
Segurança de entrada: limites, sanitização e prevenção de falhas comuns
1) Limites de payload (DoS por corpo grande)
Defina limites no parser de JSON para evitar consumo excessivo de memória e tempo:
import express from "express";const app = express();app.use(express.json({ limit: "100kb" }));Escolha o limite conforme o domínio (uploads devem usar fluxo/multipart e não JSON grande). Combine com timeouts e limites no proxy/gateway quando aplicável.
2) Rejeitar campos desconhecidos (evita mass assignment)
Um risco comum é aceitar campos extras e repassá-los para uma atualização no banco. Configure o schema para ser estrito:
import { z } from "zod";export const UpdateUserBodySchema = z.object({ name: z.string().min(2).max(80).optional(), email: z.string().email().optional(),}).strict();Com strict(), um payload como { "role": "admin" } falha, evitando que campos sensíveis sejam aceitos por engano.
3) Sanitização: normalize antes de persistir ou usar em consultas
Validação não é o mesmo que sanitização. Exemplos úteis:
- Trim/lowercase para e-mail e identificadores.
- Remover caracteres de controle em campos de texto.
- Normalizar unicode quando necessário.
Você pode fazer sanitização no parser (transformação) ou no schema com transform:
import { z } from "zod";export const EmailSchema = z.string().email().transform((v) => v.trim().toLowerCase());Evite “sanitização genérica” que tenta limpar qualquer coisa automaticamente; prefira regras explícitas por campo.
4) Prevenção de falhas comuns de validação
- Não confiar em tipos de req.query: sempre chegam como string (ou arrays). Use coerção ou parsing dedicado.
- Validar objetos aninhados e arrays com limites: imponha
max()para listas emax()para strings para reduzir abuso. - Não vazar detalhes internos: mensagens de erro devem ser úteis, mas não expor stack traces ou regras internas sensíveis. Padronize com
codeedetails. - Evitar validação parcial: valide body/params/query juntos quando fizer sentido, para retornar todos os problemas de uma vez (melhor UX e menos tentativas).
- Checar coerção perigosa: por exemplo,
Number(" ")vira0em alguns cenários. Prefira coerções da biblioteca e valide limites (min,int). - Não usar dados validados para construir SQL/NoSQL diretamente: validação ajuda, mas ainda é necessário usar queries parametrizadas/ORM e evitar concatenação de strings.
5) Proteções adicionais úteis no schema
Alguns exemplos de reforço:
- Senhas: limite máximo (ex.: 72) para evitar custos altos em hashing e compatibilidade com bcrypt.
- Strings: aplique
min/maxe, quando necessário, regex restritiva. - Enums: use
z.enum([...])em vez de string livre.
import { z } from "zod";export const RoleSchema = z.enum(["user", "admin"]);export const CreateUserBodySchema = z.object({ name: z.string().min(2).max(80), email: z.string().email().transform((v) => v.trim().toLowerCase()), password: z.string().min(8).max(72), role: RoleSchema.optional(),}).strict();Mesmo que o campo role exista, você pode decidir não aceitá-lo em rotas públicas (remova do schema). A validação deve refletir exatamente o que a rota permite.