Validação de dados no Express com TypeScript: DTOs, schemas e segurança de entrada

Capítulo 7

Tempo estimado de leitura: 10 minutos

+ Exercício

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 zod

Padronizando 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:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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, params e query.
  • 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 e max() 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 code e details.
  • 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(" ") vira 0 em 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/max e, 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.

Agora responda o exercício sobre o conteúdo:

Em uma API Express com TypeScript, qual abordagem melhor garante segurança e previsibilidade ao lidar com dados de entrada (body, params e query)?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

TypeScript não valida dados externos em runtime. A estratégia recomendada é usar schemas para validar body/params/query, separar validação de transformação, padronizar a resposta de erro e entregar ao controller apenas dados já validados (ex.: via req.validated).

Próximo capitúlo

Padronização de respostas e erros no Express: contratos, status codes e error handling

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
44%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.