Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Validar a entrada da API no Express com Zod

Como recusar dado inválido antes do controller, converter tipos vindos da query, devolver 400 com a lista de campos errados e proteger o banco.

Rodolfo Mori14 min de leitura

Validar a entrada é recusar o corpo errado na porta, antes de qualquer if do controller: você descreve o formato esperado num esquema, passa req.body por ele e responde 400 quando não bate. Quem não faz isso não escapa do trabalho — só troca uma resposta clara por um bug que aparece em outro lugar, dias depois.

Todos os exemplos são da API da Torrefação Serra Azul, que vende café em grão pela internet: /cafes é o catálogo e /pedidos é a venda. O ponto de partida é o servidor da lição Express do zero, com express.json() já ligado. A biblioteca de validação é o Zod, instalado com npm i zod.

A doca confere a carga antes de pôr no estoque

Imagine uma torrefação recebendo sacas. Se a equipe aceita qualquer etiqueta e só descobre no empacotamento que faltava peso ou origem, o erro aparece longe da porta que o deixou entrar. Validar a API é montar a conferência na doca: formato, campos obrigatórios e limites são verificados antes de o dado alcançar a regra de negócio.

O nome técnico dessa descrição é schema. No Zod, ele não é o produto nem a regra de venda; é o contrato estrutural que transforma uma entrada desconhecida em dado confiável ou numa lista clara de problemas. Antes de escrever o schema, anote um pedido válido em português e depois tente quebrá-lo de três formas: campo ausente, tipo errado e valor fora do formato. Se a resposta 400 aponta exatamente cada falha, a doca está fazendo o trabalho certo.

O bug não estoura na rota que aceitou o dado errado

Esta é a rota de cadastro sem validação nenhuma. Ela confia no corpo, espalha req.body dentro do registro e grava:

js
import express from 'express';
import { readFile, writeFile } from 'node:fs/promises';

const app = express();
app.use(express.json());

app.post('/cafes', async (req, res) => {
  const cafes = JSON.parse(await readFile('cafes.json', 'utf8'));
  const cafe = { id: cafes.length + 1, ...req.body };
  cafes.push(cafe);
  await writeFile('cafes.json', JSON.stringify(cafes, null, 2));
  res.status(201).json(cafe);
});

app.get('/estoque', async (req, res) => {
  const cafes = JSON.parse(await readFile('cafes.json', 'utf8'));
  const valor = cafes.reduce((soma, c) => soma + c.preco * c.estoqueEmKg, 0);
  res.json({ itens: cafes.length, valorEmEstoque: valor });
});

app.listen(3030, () => console.log('Serra Azul ouvindo na porta 3030'));

Agora o pedido que um front brasileiro manda sem querer: o preço com vírgula, que vira string no JSON.

bash
curl -i -X POST http://localhost:3030/cafes \
  -H 'Content-Type: application/json' \
  -d '{"nome":"Catuaí Vermelho","preco":"74,50","estoqueEmKg":8}'
HTTP/1.1 201 Created X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 66 ETag: W/"42-a3S177cOugSnAn+Fb5BYzm2DRjs" Date: Sat, 22 Aug 2026 23:03:06 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:2,“nome”:“Catuaí Vermelho”,“preco”:“74,50”,“estoqueEmKg”:8}

201 Created. Nenhum erro, nenhum aviso. O registro entrou no arquivo com o preço em texto, e vai continuar lá amanhã:

json
[
  {
    "id": 1,
    "nome": "Bourbon Amarelo",
    "preco": 68.9,
    "estoqueEmKg": 12
  },
  {
    "id": 2,
    "nome": "Catuaí Vermelho",
    "preco": "74,50",
    "estoqueEmKg": 8
  }
]

A conta que soma o valor do estoque está em outra rota, escrita por outra pessoa, semanas antes. Ela também não reclama:

bash
curl http://localhost:3030/estoque
{"itens":2,"valorEmEstoque":null}

valorEmEstoque: null. Não é 0, não é erro 500, não é campo faltando: é o NaN que "74,50" * 8 produziu, e que o JSON.stringify transforma em null na saída. Dá para ver os três passos fora do servidor:

js
import { readFile } from 'node:fs/promises';

const cafes = JSON.parse(await readFile('cafes.json', 'utf8'));
const valor = cafes.reduce((soma, c) => soma + c.preco * c.estoqueEmKg, 0);

console.log(valor);
console.log(typeof valor);
console.log(JSON.stringify({ valorEmEstoque: valor }));
NaN number {"valorEmEstoque":null}

Esse é o custo real de não validar: o NaN é contagioso (qualquer conta com ele vira NaN), atravessa o JSON.stringify disfarçado de null e chega no relatório do dono da torrefação como um campo vazio. Se você nunca reparou nessa diferença entre null, undefined e NaN, vale a leitura de null, undefined e NaN.

O esquema: descrever o café antes de aceitar o café

Um esquema é a descrição do formato que a rota aceita. No Zod ele é um objeto de regras, e vive num arquivo separado, ao lado das rotas do recurso:

js
// esquema.mjs
import { z } from 'zod';

export const cafeSchema = z.object({
  nome: z.string().min(3, 'o nome precisa ter ao menos 3 letras').max(60),
  preco: z.number().positive('o preço precisa ser maior que zero'),
  estoqueEmKg: z.int().nonnegative(),
  moagem: z.enum(['graos', 'fina', 'media', 'grossa']),
  origem: z.string().optional(),
});

Leia como uma frase: o nome é texto de 3 a 60 caracteres, o preço é número maior que zero, o estoque é inteiro não negativo, a moagem é uma de quatro opções e a origem é opcional. O segundo argumento de min e positive é a mensagem que vai aparecer para quem chamou a rota — sem ele, o Zod usa a própria.

O método parse roda as regras e devolve o dado já no formato certo:

js
import { cafeSchema } from './esquema.mjs';

const cafe = cafeSchema.parse({
  nome: 'Catuaí Vermelho',
  preco: 74.5,
  estoqueEmKg: 8,
  moagem: 'graos',
});

console.log(cafe);
{ nome: 'Catuaí Vermelho', preco: 74.5, estoqueEmKg: 8, moagem: 'graos' }

parse lança; safeParse devolve

Com dado inválido, parse lança uma exceção. Dentro de uma rota síncrona, isso derruba a requisição no handler de erro do Express; fora dele, derruba o processo:

js
const cafe = cafeSchema.parse({
  nome: 'Ca',
  preco: 'abc',
  estoqueEmKg: 8,
  moagem: 'graos',
});
file:///private/tmp/serra-azul/parse-erro.mjs:3 const cafe = cafeSchema.parse({ ^

ZodError: [ { “origin”: “string”, “code”: “too_small”, “minimum”: 3, “inclusive”: true, “path”: [ “nome” ], “message”: “o nome precisa ter ao menos 3 letras” }, { “expected”: “number”, “code”: “invalid_type”, “path”: [ “preco” ], “message”: “Invalid input: expected number, received string” } ] at file:///private/tmp/serra-azul/parse-erro.mjs:3:25 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) Node.js v24.16.0

Repare em duas coisas nesse erro. Primeiro: ele traz todos os problemas de uma vez, não só o primeiro — nome e preco vieram juntos. Segundo: cada problema tem um path, que é o caminho até o campo dentro do objeto.

Para uma rota HTTP, porém, exceção é o instrumento errado. Você não quer try/catch em volta de cada controller para dizer “o preço veio errado”. O safeParse faz a mesma validação e devolve um objeto em vez de lançar:

js
import { cafeSchema } from './esquema.mjs';

const resultado = cafeSchema.safeParse({
  nome: 'Ca',
  preco: 'abc',
  estoqueEmKg: 8.5,
  moagem: 'moída na hora',
});

console.log('success:', resultado.success);
console.log('data:', resultado.data);

for (const problema of resultado.error.issues) {
  console.log(problema.path.join('.'), '→', problema.message);
}
success: false data: undefined nome → o nome precisa ter ao menos 3 letras preco → Invalid input: expected number, received string estoqueEmKg → Invalid input: expected int, received number moagem → Invalid option: expected one of "graos"|"fina"|"media"|"grossa"

resultado.success é o booleano que a rota consulta. Quando é true, o dado limpo está em resultado.data; quando é false, a lista de problemas está em resultado.error.issues. Note que 8.5 foi recusado por não ser inteiro: café a granel a gente vende em quilo fracionado, mas o campo foi declarado int, e o esquema cobra o que foi declarado.

Do issue do Zod para um 400 que o front consegue usar

A rota vira quatro linhas de decisão. Se falhou, responde 400 com a lista de campos; se passou, segue o trabalho usando resultado.data:

js
app.post('/cafes', (req, res) => {
  const resultado = cafeSchema.safeParse(req.body);

  if (!resultado.success) {
    return res.status(400).json({
      erro: 'Corpo da requisição inválido',
      campos: resultado.error.issues.map((problema) => ({
        campo: problema.path.join('.') || '(corpo)',
        mensagem: problema.message,
      })),
    });
  }

  res.status(201).json({ id: 3, ...resultado.data });
});

O formato da resposta é uma decisão sua, e ela importa: quem consome precisa saber qual campo errou para pintar o input de vermelho. Uma string solta (“dados inválidos”) obriga o front a adivinhar. O path.join('.') resolve campo aninhado (endereco.cep) de graça, e o || '(corpo)' cobre o caso em que o problema é o objeto inteiro, e não um campo.

400 é o status certo aqui — o cliente mandou algo que a API não entende. A diferença entre ele, o 404 e o 409 está em métodos HTTP e status code.

Sete requisições contra a mesma rota

Estas são as sete requisições que eu mandei contra a rota acima, em sequência, num terminal só: seis corpos errados de jeitos diferentes e, na última linha, o corpo certo.

bash
API=http://localhost:3030/cafes
post() { curl -s -X POST "$API" -H 'Content-Type: application/json' -d "$1"; echo; }

post '{"nome":"Catuaí Vermelho","preco":"74,50","estoqueEmKg":8,"moagem":"graos"}'
post '{"nome":"Ca","preco":74.5,"estoqueEmKg":8.5,"moagem":"graos"}'
post '{"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8}'
post '{}'
post '{"nome":"Catuaí Vermelho","preco":-10,"estoqueEmKg":8,"moagem":"na hora"}'
curl -s -X POST "$API" -d '{"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8,"moagem":"graos"}'; echo
post '{"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8,"moagem":"graos"}'
{"erro":"Corpo da requisição inválido","campos":[{"campo":"preco","mensagem":"Invalid input: expected number, received string"}]} {"erro":"Corpo da requisição inválido","campos":[{"campo":"nome","mensagem":"o nome precisa ter ao menos 3 letras"},{"campo":"estoqueEmKg","mensagem":"Invalid input: expected int, received number"}]} {"erro":"Corpo da requisição inválido","campos":[{"campo":"moagem","mensagem":"Invalid option: expected one of \"graos\"|\"fina\"|\"media\"|\"grossa\""}]} {"erro":"Corpo da requisição inválido","campos":[{"campo":"nome","mensagem":"Invalid input: expected string, received undefined"},{"campo":"preco","mensagem":"Invalid input: expected number, received undefined"},{"campo":"estoqueEmKg","mensagem":"Invalid input: expected number, received undefined"},{"campo":"moagem","mensagem":"Invalid option: expected one of \"graos\"|\"fina\"|\"media\"|\"grossa\""}]} {"erro":"Corpo da requisição inválido","campos":[{"campo":"preco","mensagem":"o preço precisa ser maior que zero"},{"campo":"moagem","mensagem":"Invalid option: expected one of \"graos\"|\"fina\"|\"media\"|\"grossa\""}]} {"erro":"Corpo da requisição inválido","campos":[{"campo":"(corpo)","mensagem":"Invalid input: expected object, received undefined"}]} {"id":3,"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8,"moagem":"graos"}

Três detalhes dessa saída que valem mais que a teoria toda:

  • Campo ausente e campo com tipo errado dão mensagens diferentes. Na quarta linha, o corpo vazio produziu expected string, received undefined — o Zod trata “não veio” como “veio undefined”. Já o estoqueEmKg do corpo vazio reclama de number, não de int: sem valor nenhum, ele nem chega na regra do inteiro.
  • A sexta requisição não tem Content-Type. Sem esse cabeçalho, o express.json() não parseia nada e req.body fica undefined no Express 5 — daí o expected object, received undefined. É a causa número um de “mandei o corpo certo e a API diz que está vazio”.
  • As mensagens saem em inglês, porque são as do Zod. A do nome e a do preco negativo saíram em português porque eu escrevi essas duas no esquema.

Dá para traduzir todas de uma vez, sem reescrever esquema nenhum:

js
import { z } from 'zod';
import { cafeSchema } from './esquema.mjs';

z.config(z.locales.pt());

const resultado = cafeSchema.safeParse({
  nome: 'Ca',
  preco: '74,50',
  estoqueEmKg: 8.5,
  moagem: 'na hora',
});

for (const problema of resultado.error.issues) {
  console.log(problema.path.join('.'), '→', problema.message);
}
nome → o nome precisa ter ao menos 3 letras preco → Tipo inválido: esperado número, recebido string estoqueEmKg → Tipo inválido: esperado int, recebido número moagem → Opção inválida: esperada uma das "graos"|"fina"|"media"|"grossa"

z.config(z.locales.pt()) roda uma vez, na subida do servidor, e troca só as mensagens padrão — as suas continuam intactas.

O JSON quebrado nem chega no seu esquema

Falta uma oitava requisição na lista, e ela é diferente das outras: um JSON com erro de sintaxe. Esse corpo morre antes da sua rota, dentro do express.json(), e a resposta padrão do Express é uma página HTML com o stack trace inteiro — caminho de arquivos do servidor incluído:

bash
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
  -X POST http://localhost:3030/cafes \
  -H 'Content-Type: application/json' \
  -d '{"nome":"Catuaí Vermelho",}'
400 text/html; charset=utf-8

Uma API que responde JSON em tudo e HTML nesse caso quebra o cliente, que tenta dar JSON.parse na página de erro. O conserto é um handler de erro que reconhece os dois problemas do parser de corpo:

js
app.use(express.json({ limit: '10kb' }));

// ... as rotas ...

app.use((err, req, res, next) => {
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({ erro: 'JSON malformado no corpo da requisição' });
  }
  if (err.type === 'entity.too.large') {
    return res.status(413).json({ erro: 'Corpo grande demais: o limite é 10kb' });
  }
  next(err);
});
bash
curl -s -w '\n' -X POST http://localhost:3030/cafes \
  -H 'Content-Type: application/json' -d '{"nome":"Catuaí Vermelho",}'

curl -s -w '\n' -X POST http://localhost:3030/cafes \
  -H 'Content-Type: application/json' --data-binary @grande.json
{"erro":"JSON malformado no corpo da requisição"} {"erro":"Corpo grande demais: o limite é 10kb"}

A primeira linha é a resposta ao JSON quebrado; a segunda, a um grande.json de 20 KB contra o limite de 10 KB. Esse limit é validação também: sem ele, o padrão de 100 KB por requisição é o que separa a sua API de um upload acidental de arquivo inteiro no campo nome. O handler acima é um caso particular do tratamento de erro centralizado.

Query string: tudo chega como texto

Aqui está a armadilha que faz gente desistir de validar a busca. Na query string, todo valor é string?pagina=2 entrega "2", com aspas. Um esquema com z.number() recusa os dois campos:

js
const buscaSchema = z.object({
  pagina: z.number().int().positive().default(1),
  limite: z.number().int().min(1).max(50).default(10),
});

app.get('/cafes', (req, res) => {
  const resultado = buscaSchema.safeParse(req.query);
  if (!resultado.success) {
    return res.status(400).json({
      erro: 'Query string inválida',
      campos: resultado.error.issues.map((p) => ({
        campo: p.path.join('.'),
        mensagem: p.message,
      })),
    });
  }
  res.json({ recebido: req.query, validado: resultado.data });
});
bash
curl -s -w '\n' 'http://localhost:3030/cafes?pagina=2&limite=5'
{"erro":"Query string inválida","campos":[{"campo":"pagina","mensagem":"Invalid input: expected number, received string"},{"campo":"limite","mensagem":"Invalid input: expected number, received string"}]}

A gambiarra clássica é encher a rota de Number(req.query.pagina) || 1. O jeito certo é pedir ao esquema que converta: z.coerce.number() roda o Number() antes de validar.

js
const buscaSchema = z.object({
  pagina: z.coerce.number().int().positive().default(1),
  limite: z.coerce.number().int().min(1).max(50).default(10),
  moagem: z.enum(['graos', 'fina', 'media', 'grossa']).optional(),
  precoMax: z.coerce.number().positive().optional(),
});
bash
curl -s -w '\n' 'http://localhost:3030/cafes?pagina=2&limite=5'
curl -s -w '\n' 'http://localhost:3030/cafes?limite=500'
curl -s -w '\n' 'http://localhost:3030/cafes?pagina=abc'
curl -s -w '\n' 'http://localhost:3030/cafes?pagina='
curl -s -w '\n' 'http://localhost:3030/cafes?pagina=2&pagina=3'
curl -s -w '\n' 'http://localhost:3030/cafes'
{"recebido":{"pagina":"2","limite":"5"},"validado":{"pagina":2,"limite":5}} {"erro":"Query string inválida","campos":[{"campo":"limite","mensagem":"Too big: expected number to be <=50"}]} {"erro":"Query string inválida","campos":[{"campo":"pagina","mensagem":"Invalid input: expected number, received NaN"}]} {"erro":"Query string inválida","campos":[{"campo":"pagina","mensagem":"Too small: expected number to be >0"}]} {"erro":"Query string inválida","campos":[{"campo":"pagina","mensagem":"Invalid input: expected number, received NaN"}]} {"recebido":{},"validado":{"pagina":1,"limite":10}}

A primeira linha mostra o antes e o depois lado a lado: "2" entrou como texto e saiu como 2. As quatro do meio são os casos que ninguém lembra de testar na mão:

query o que chega resposta
?limite=500 "500" 400 — passa do teto de 50
?pagina=abc "abc" 400 — Number('abc') é NaN
?pagina= "" 400 — Number('') é 0, e 0 não é positivo
?pagina=2&pagina=3 ["2","3"] 400 — repetiu o parâmetro, virou array

Os dois últimos são os importantes. Um campo vazio na URL não vira undefined: vira string vazia, que o Number() converte para zero — se o seu default(1) fosse a única proteção, a paginação começaria na página zero. E parâmetro repetido, no Express, vira array; Number(['2','3']) é NaN, e o esquema segura. O default do Zod, vale dizer, só entra quando o campo não veio — é o que a última linha mostra: sem query nenhuma, o esquema entrega a página 1 com limite 10, e a rota não precisa de nenhum if.

Um validar(esquemas) que serve para qualquer rota

Repetir o bloco do safeParse em vinte rotas é o mesmo problema de sempre. Isso é trabalho de middleware: uma função que roda antes do controller e decide se a requisição segue.

O primeiro impulso é validar e sobrescrever o original, como quase todo tutorial de Express 4 ensina:

js
function validarQuery(schema) {
  return (req, res, next) => {
    const resultado = schema.safeParse(req.query);
    if (!resultado.success) return res.status(400).json({ erro: 'inválido' });
    req.query = resultado.data;
    next();
  };
}

No Express 5 isso não funciona mais. Basta um GET /cafes?pagina=2 para o terminal do servidor cuspir isto:

TypeError: Cannot set property query of #<IncomingMessage> which has only a getter at file:///private/tmp/serra-azul/api-mw-erro.js:14:15 at Layer.handleRequest (/private/tmp/serra-azul/node_modules/router/lib/layer.js:152:17) at next (/private/tmp/serra-azul/node_modules/router/lib/route.js:157:13)

req.query virou uma propriedade só de leitura, calculada sob demanda a partir da URL. A requisição responde 500 e o erro cai no handler central — um 500 causado justamente pelo código que existia para evitar erro. E o detalhe que confunde: das três origens, só a query é assim.

js
app.post('/teste/:id', (req, res) => {
  const resultado = {};
  try { req.body = { ok: 'body' }; resultado.body = 'aceitou'; }
  catch (e) { resultado.body = e.message; }
  try { req.params = { id: 1 }; resultado.params = 'aceitou'; }
  catch (e) { resultado.params = e.message; }
  try { req.query = { pagina: 1 }; resultado.query = 'aceitou'; }
  catch (e) { resultado.query = e.message; }
  res.json(resultado);
});
{"body":"aceitou","params":"aceitou","query":"Cannot set property query of #<IncomingMessage> which has only a getter"}

A saída é não brigar com o Express: guarde o resultado num campo seu.

js
// validar.mjs
export function validar(esquemas) {
  return (req, res, next) => {
    req.valido = {};

    for (const [origem, schema] of Object.entries(esquemas)) {
      const resultado = schema.safeParse(req[origem]);

      if (!resultado.success) {
        return res.status(400).json({
          erro: `Dados inválidos em ${origem}`,
          campos: resultado.error.issues.map((problema) => ({
            campo: problema.path.join('.') || '(raiz)',
            mensagem: problema.message,
          })),
        });
      }

      req.valido[origem] = resultado.data;
    }

    next();
  };
}

A chave do objeto — body, query ou params — é o próprio nome da propriedade do req, então o mesmo middleware valida as três origens. Se você ainda confunde qual é qual, a lição req.params, req.query e req.body separa as três. As rotas ficam assim:

js
app.post('/cafes', validar({ body: cafeSchema }), (req, res) => {
  res.status(201).json({ id: 3, ...req.valido.body });
});

app.get('/cafes', validar({ query: buscaSchema }), (req, res) => {
  res.json({ pagina: req.valido.query.pagina, limite: req.valido.query.limite });
});

app.get('/cafes/:id', validar({ params: idSchema }), (req, res) => {
  res.json({ buscando: req.valido.params.id, tipo: typeof req.valido.params.id });
});
bash
curl -s -w '\n' 'http://localhost:3030/cafes?pagina=3&limite=5'
curl -s -w '\n' 'http://localhost:3030/cafes?limite=500'
curl -s -w '\n' 'http://localhost:3030/cafes/7'
curl -s -w '\n' 'http://localhost:3030/cafes/abc'
curl -s -w '\n' -X POST http://localhost:3030/cafes \
  -H 'Content-Type: application/json' \
  -d '{"nome":"Ca","preco":74.5,"estoqueEmKg":8,"moagem":"graos"}'
{"pagina":3,"limite":5} {"erro":"Dados inválidos em query","campos":[{"campo":"limite","mensagem":"Too big: expected number to be <=50"}]} {"buscando":7,"tipo":"number"} {"erro":"Dados inválidos em params","campos":[{"campo":"id","mensagem":"Invalid input: expected number, received NaN"}]} {"erro":"Dados inválidos em body","campos":[{"campo":"nome","mensagem":"o nome precisa ter ao menos 3 letras"}]}

A terceira linha é a que paga a fatura: /cafes/7 entregou o id como "tipo":"number", já convertido pelo z.coerce.number() do idSchema. Sem isso, req.params.id seria a string "7" — e a comparação cafe.id === id falharia em silêncio contra qualquer array ou banco que guarde o id como número. E /cafes/abc nem chega no controller.

Campo extra: o que o Zod joga fora e o que você deixa entrar

Se o corpo trouxer campos que o esquema não declara, o z.object do Zod descarta por padrão. Existem outros dois comportamentos:

js
const corpo = {
  nome: 'Catuaí Vermelho',
  preco: 74.5,
  estoqueEmKg: 8,
  moagem: 'graos',
  precoDeCusto: 21.4,
  destaque: true,
};

const padrao = cafeSchema.safeParse(corpo);
console.log('padrão  →', padrao.success, padrao.data);

const estrito = cafeSchema.strict().safeParse(corpo);
console.log('strict  →', estrito.success);
console.log(estrito.error.issues);

const solto = cafeSchema.loose().safeParse(corpo);
console.log('loose   →', solto.success, solto.data);
padrão → true { nome: 'Catuaí Vermelho', preco: 74.5, estoqueEmKg: 8, moagem: 'graos' } strict → false [ { code: 'unrecognized_keys', keys: [ 'precoDeCusto', 'destaque' ], path: [], message: 'Unrecognized keys: "precoDeCusto", "destaque"' } ] loose → true { nome: 'Catuaí Vermelho', preco: 74.5, estoqueEmKg: 8, moagem: 'graos', precoDeCusto: 21.4, destaque: true }

O padrão limpa, .strict() recusa e .loose() deixa passar tudo. Só que essa limpeza só existe se você usar o data. Duas rotas idênticas, com a mesma validação, e uma única diferença — uma espalha req.body, a outra espalha req.valido.body:

js
app.post('/cafes-cru', validar({ body: cafeSchema }), (req, res) => {
  res.status(201).json({ id: 3, ...req.body });
});

app.post('/cafes', validar({ body: cafeSchema }), (req, res) => {
  res.status(201).json({ id: 3, ...req.valido.body });
});
bash
CORPO='{"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8,"moagem":"graos","precoDeCusto":21.4,"destaque":true}'

curl -s -w '\n' -X POST http://localhost:3030/cafes-cru \
  -H 'Content-Type: application/json' -d "$CORPO"
curl -s -w '\n' -X POST http://localhost:3030/cafes \
  -H 'Content-Type: application/json' -d "$CORPO"
{"id":3,"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8,"moagem":"graos","precoDeCusto":21.4,"destaque":true} {"id":3,"nome":"Catuaí Vermelho","preco":74.5,"estoqueEmKg":8,"moagem":"graos"}

A primeira rota validou e mesmo assim vazou o precoDeCusto — que é informação interna da torrefação — para dentro da resposta e do registro salvo. Troque precoDeCusto por role: "admin" num cadastro de usuário e você tem a falha conhecida como mass assignment, que é como contas comuns viram administradoras. A regra é curta: depois de validar, o req.body não é mais usado. Só req.valido.body.

Formato não é regra de negócio

Um esquema responde “esse dado tem a cara certa?”. Ele não responde “esse pedido faz sentido hoje?”. A segunda pergunta depende do banco, e é a rota que responde — com outro status:

js
const estoque = [
  { id: 1, nome: 'Bourbon Amarelo', preco: 68.9, estoqueEmKg: 12 },
  { id: 2, nome: 'Catuaí Vermelho', preco: 74.5, estoqueEmKg: 3 },
];

const pedidoSchema = z.object({
  email: z.email('e-mail do cliente em formato inválido'),
  cafeId: z.coerce.number().int().positive(),
  quantidadeEmKg: z.number().positive().max(20),
});

app.post('/pedidos', validar({ body: pedidoSchema }), (req, res) => {
  const { cafeId, quantidadeEmKg } = req.valido.body;

  const cafe = estoque.find((c) => c.id === cafeId);
  if (!cafe) {
    return res.status(404).json({ erro: `Não temos o café ${cafeId} no catálogo` });
  }

  if (quantidadeEmKg > cafe.estoqueEmKg) {
    return res.status(409).json({
      erro: `Só restam ${cafe.estoqueEmKg} kg de ${cafe.nome}`,
      disponivel: cafe.estoqueEmKg,
    });
  }

  res.status(201).json({ id: 41, total: +(cafe.preco * quantidadeEmKg).toFixed(2) });
});

Quatro pedidos contra um catálogo em que o Catuaí Vermelho tem 3 kg em estoque:

bash
PED=http://localhost:3030/pedidos
pedido() { curl -s -w '\n' -X POST "$PED" -H 'Content-Type: application/json' -d "$1"; }

pedido '{"email":"ana.arroba.email","cafeId":2,"quantidadeEmKg":2}'
pedido '{"email":"ana@serraazul.com","cafeId":99,"quantidadeEmKg":2}'
pedido '{"email":"ana@serraazul.com","cafeId":2,"quantidadeEmKg":5}'
pedido '{"email":"ana@serraazul.com","cafeId":2,"quantidadeEmKg":2}'
{"erro":"Dados inválidos em body","campos":[{"campo":"email","mensagem":"e-mail do cliente em formato inválido"}]} {"erro":"Não temos o café 99 no catálogo"} {"erro":"Só restam 3 kg de Catuaí Vermelho","disponivel":3} {"id":41,"total":149}

Três respostas de erro, três status diferentes, e cada um diz uma coisa específica: 400 é “você escreveu errado”, 404 é “isso não existe” e 409 é “está tudo certo, mas conflita com o estado de agora”. Quem consome a API trata os três de formas diferentes — 400 volta para o formulário, 409 vira uma mensagem de estoque.

A divisa entre o esquema e a rota é simples: se a resposta depende de consultar alguma coisa, não é trabalho do esquema. “É um e-mail?” e “é positivo?” ficam no Zod, porque valem sempre. “Tem estoque?” e “esse café existe?” ficam na rota, porque a resposta muda de minuto em minuto.

Normalizar também é trabalho do esquema

Além de aceitar ou recusar, o esquema pode arrumar o dado. Isso tira do controller um punhado de trim() e toLowerCase() espalhados:

js
const clienteSchema = z.object({
  nome: z.string().trim().min(3),
  email: z.email().toLowerCase(),
  telefone: z.string().regex(/^\d{11}$/, 'telefone com DDD, só números'),
});

console.log(
  clienteSchema.parse({
    nome: '   Ana Prado  ',
    email: 'ANA@SerraAzul.com',
    telefone: '11987654321',
  }),
);
{ nome: 'Ana Prado', email: 'ana@serraazul.com', telefone: '11987654321' }

O trim() roda antes do min(3) — mandar " A " não passa, porque o que sobra é uma letra só. E o telefone com máscara ((11) 98765-4321) é recusado pelo regex, o que obriga o front a mandar só dígitos e evita ter dois formatos no banco:

js
const r = clienteSchema.safeParse({
  nome: '  A ',
  email: 'ANA@SerraAzul.com',
  telefone: '(11) 98765-4321',
});

console.log(r.error.issues.map((p) => `${p.path.join('.')} → ${p.message}`));
[ 'nome → Too small: expected string to have >=3 characters', 'telefone → telefone com DDD, só números' ]

Guardar o telefone já normalizado é o que faz a busca por cliente funcionar seis meses depois. Validação e normalização são o mesmo movimento: um só lugar decide como o dado entra.

O que vem depois

Volte na sua API e faça uma coisa só: escolha a rota POST mais usada, escreva o esquema dela e ligue o validar({ body }). Depois mande um corpo errado de propósito e leia a resposta — se ela não diz qual campo falhou, o esquema ainda não está pronto.

O passo seguinte da trilha de Node é receber o que não cabe em JSON: arquivo. E o guia completo de Node mostra onde a validação entra na ordem de estudo, entre o middleware e a autenticação — que é, não por acaso, a próxima coisa que você não vai querer construir em cima de um req.body em que ninguém olhou.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • validacao
  • zod
  • express
  • api
  • seguranca
  • node

Perguntas frequentes

Zod, Joi ou Yup — qual usar num projeto novo?
Os três resolvem o mesmo problema. O Zod ganha em projeto que usa TypeScript, porque o tipo sai do próprio esquema com z.infer e você para de manter tipo e validação em dois lugares. Em projeto legado que já usa Joi, não vale a migração só por moda.
Se o front já valida o formulário, preciso validar na API também?
Precisa. A validação do front existe para dar mensagem rápida a quem está digitando; ela some no primeiro curl, no Insomnia ou num app antigo que ainda chama a sua rota. A API é a única camada que ninguém pula.
Onde os esquemas devem morar no projeto?
Ao lado das rotas do recurso, num arquivo por recurso — cafes.schema.js junto de cafes.routes.js. Uma pasta central de esquemas parece organizada no começo e vira um depósito que ninguém acha nada depois de vinte rotas.
Dá para reaproveitar o esquema como tipo do TypeScript?
Dá, e é o melhor motivo para escolher o Zod. O utilitário z.infer aplicado ao typeof do esquema devolve o tipo do dado já validado. Mudou a validação, o TypeScript acusa todo lugar que dependia do formato antigo.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, Express 5.2.1 e Zod 4.4.3, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Zod — Basic usage — zod.dev
  2. Express 5 — express.json() — expressjs.com
  3. OWASP — Input Validation Cheat Sheet — cheatsheetseries.owasp.org

Continue por aqui