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.
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:
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.
curl -i -X POST http://localhost:3030/cafes \
-H 'Content-Type: application/json' \
-d '{"nome":"Catuaí Vermelho","preco":"74,50","estoqueEmKg":8}'{“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ã:
[
{
"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:
curl http://localhost:3030/estoquevalorEmEstoque: 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:
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 }));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:
// 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:
import { cafeSchema } from './esquema.mjs';
const cafe = cafeSchema.parse({
nome: 'Catuaí Vermelho',
preco: 74.5,
estoqueEmKg: 8,
moagem: 'graos',
});
console.log(cafe);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:
const cafe = cafeSchema.parse({
nome: 'Ca',
preco: 'abc',
estoqueEmKg: 8,
moagem: 'graos',
});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:
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);
}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:
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.
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"}'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 “veioundefined”. Já oestoqueEmKgdo corpo vazio reclama denumber, não deint: sem valor nenhum, ele nem chega na regra do inteiro. - A sexta requisição não tem
Content-Type. Sem esse cabeçalho, oexpress.json()não parseia nada ereq.bodyficaundefinedno Express 5 — daí oexpected 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
nomee a dopreconegativo saíram em português porque eu escrevi essas duas no esquema.
Dá para traduzir todas de uma vez, sem reescrever esquema nenhum:
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);
}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:
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",}'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:
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);
});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.jsonA 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:
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 });
});curl -s -w '\n' 'http://localhost:3030/cafes?pagina=2&limite=5'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.
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(),
});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'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:
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:
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.
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);
});A saída é não brigar com o Express: guarde o resultado num campo seu.
// 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:
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 });
});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"}'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:
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);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:
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 });
});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"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:
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:
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}'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:
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',
}),
);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:
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}`));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.
Perguntas frequentes
Zod, Joi ou Yup — qual usar num projeto novo?
Se o front já valida o formulário, preciso validar na API também?
Onde os esquemas devem morar no projeto?
Dá para reaproveitar o esquema como tipo do TypeScript?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- Zod — Basic usage — zod.dev
- Express 5 — express.json() — expressjs.com
- OWASP — Input Validation Cheat Sheet — cheatsheetseries.owasp.org



