Skip to content
This page has been auto-translated and may contain errors.View in English

Onde a validação deve ficar

Três capítulos de schemas, todos rodados manualmente em objetos escritos poucas linhas acima deles. Dados reais chegam pela rede, e um schema só vale alguma coisa no lugar onde esses dados chegam.

A questão é onde colocar a verificação, e a resposta muda como o resto do código é escrito.

O caminho de um envio

Um formulário de cadastro envia um POST para o servidor. Entre a chegada da requisição e a execução do seu código, uma coisa acontece:

text
form  ──POST──▶  validação  ──▶  route handler  ──▶  resposta

                     └──▶  400 com os erros

A validação fica no meio, e é ela quem decide qual dos dois desfechos a requisição vai ter.

Falha. O schema encontra problemas, a requisição para ali, e um 400 Bad Request volta carregando uma lista do que deu errado. O route handler nunca chega a rodar.

Passa. Os dados processados seguem para o route handler, que faz o trabalho de verdade e responde com 201 Created.

O handler só recebe dados que já bateram com o schema.

JunoO caminho de um envio A comparação com o scanner ainda funciona, só que agora a máquina fica em uma porta, não em um banco.

Nada chega ao ambiente atrás dela sem passar por ali, então esse ambiente pode parar de perguntar se as coisas foram verificadas.

JunoO caminho de um envio O 400 faz mais do que relatar um problema. É assim que o front-end sabe quais campos marcar, e é por isso que a resposta carrega uma lista de pares campo-e-mensagem, não uma única frase.

O código de status importa por si só também. Um 400 diz que o cliente enviou algo errado e deveria corrigir antes de tentar de novo. Um 500 diz que o servidor quebrou. Retornar 500 para uma validação que falhou joga os clientes em loops de nova tentativa sobre uma requisição que nunca vai dar certo.

JunoO caminho de um envio Vale a pena pensar com cuidado sobre o que vai no corpo de um 400. Nomes de campos e suas próprias mensagens tudo bem. O valor recebido não, porque um cadastro rejeitado contém uma senha.

Uma resposta de erro é justamente um lugar onde as pessoas esquecem que estão registrando dados em algum lugar, e o mesmo vale para o que você loga nesse caminho.

Também existe uma decisão escondida no diagrama sobre onde a transformação acontece. O valor processado é o que segue adiante, então fazer trim e lowercase aqui significa que tudo que vem depois vê a versão já limpa, e nenhum handler precisa se lembrar de normalizar nada.

Esse é o argumento para colocar isso no schema, e ele só se sustenta enquanto os handlers pararem de ler o corpo bruto.

Por que a verificação vai na frente

A alternativa é validar no topo de cada route handler, o que funciona mas não se sustenta.

js
// A versão que se desalinha com o tempo
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', (req, res) => {
  const result = registerSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ success: false, errors: result.error.issues })
  }

  // ...o trabalho de verdade, eventualmente
})

Toda rota repete esse bloco. Código repetido se desalinha: uma rota mapeia os erros e outra os retorna crus, uma esquece o return e continua a execução dentro do handler mesmo depois de responder, uma rota nova copia qualquer versão que estivesse mais perto.

Colocar a verificação na frente faz dela um único pedaço de código compartilhado por todas as rotas, e faz com que a validação de uma rota fique visível na sua definição, em vez de escondida no corpo dela.

JunoPor que a verificação vai na frente As duas versões rodam o mesmo schema. A diferença é quantas cópias do código ao redor existem.

Uma cópia pode ser corrigida uma vez só. Doze cópias significam descobrir qual delas tem o bug.

JunoPor que a verificação vai na frente O return que falta é o bug específico que vale a pena conhecer, porque ele falha em silêncio. res.status(400).json(...) envia a resposta e não interrompe a função, então, sem um return, o handler continua rodando e processa os dados inválidos mesmo assim.

Você recebe um 400 no navegador e um usuário criado no banco de dados. Tudo parece correto de fora, o que é o pior tipo de erro que existe.

JunoPor que a verificação vai na frente O ganho mais profundo é que a validação se torna declarativa e, portanto, auditável. Quando cada rota nomeia seu schema na própria definição, "quais endpoints validam sua entrada" vira uma pergunta respondida lendo uma lista, e uma rota sem schema fica visível em vez de apenas indocumentada.

Tente responder essa pergunta em quarenta handlers que validam internamente cada um. Você não consegue, sem ler os quarenta, e a resposta muda toda vez que alguém adiciona uma rota.

Também vale a pena colocar o mesmo schema na frente do formulário, para que o navegador capture erros simples sem precisar de uma ida e volta ao servidor. Mesmo arquivo, mesmas regras, e a verificação do servidor continua sendo a que realmente conta, porque a cópia do navegador pode ser editada por quem estiver rodando ela.

O que o handler recebe

Um handler que fica atrás da validação pode ser curto, porque ele pode assumir o formato dos dados que recebeu:

js
import { validate } from './middleware/validate.js'
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', validate(registerSchema), (req, res) => {
  const userData = req.validatedData

  // Lógica de negócio, trabalhando com dados que já bateram com o schema.

  res.status(201).json({ success: true, user: { email: userData.email } })
})

Três coisas ficam entre o caminho e o handler agora: validate(registerSchema) é a verificação, e o handler só roda se ela passar.

O handler lê req.validatedData, não req.body. Essa distinção é o arranjo todo. req.body é o que chegou; req.validatedData é o que sobreviveu, já com as transformações aplicadas.

JunoO que o handler recebe Dois nomes para o que parece ser a mesma coisa, e a diferença importa.

req.body é o que foi enviado. req.validatedData é o que passou pela verificação. Leia o segundo e o handler nunca precisa ficar em dúvida.

JunoO que o handler recebe Esse é o hábito para reforçar na revisão de código: uma vez que uma rota tem validação na frente, req.body não deveria aparecer no handler de jeito nenhum. Uma única leitura solta dele contorna toda verificação que você montou.

É uma coisa mecânica de procurar, o que faz dela uma boa regra. Dê um grep no handler procurando req.body, e se ele estiver ali depois de uma chamada a validate, esse é o problema encontrado.

JunoO que o handler recebe Ler req.body depois da validação reintroduz o mass assignment, e vale a pena nomear por quê. O schema remove as chaves que não descreve, então o objeto processado contém só o que você pediu. O corpo bruto ainda contém tudo o que foi enviado, incluindo aquele isAdmin que um cliente adicionou esperançoso.

A solução é rotear os dados, não decorar uma regra. Quando o valor processado é o único que os handlers conseguem alcançar, esquecer deixa de ser possível, o que supera uma revisão de código que pega isso na maior parte das vezes.

Coloque em prática

Aqui está uma rota que valida dentro do handler:

js
import * as z from 'zod'
import { saveSubscriber } from './services/subscribers.js'

const subscribeSchema = z.object({
  email: z.email('Digite um endereço de e-mail válido'),
})

app.post('/api/subscribe', (req, res) => {
  const result = subscribeSchema.safeParse(req.body)

  if (!result.success) {
    res.status(400).json({ success: false, errors: result.error.issues })
  }

  const { email } = req.body
  saveSubscriber(email)

  res.status(201).json({ success: true })
})

Encontre três problemas nela.

Compare suas respostas

1. O return que falta. res.status(400).json(...) envia uma resposta e continua executando, então uma requisição inválida recebe um 400 e chega até saveSubscriber. O cliente vê uma rejeição enquanto o assinante é salvo mesmo assim.

2. Ela lê req.body em vez de result.data. Mesmo com o return corrigido, o valor usado é o bruto, então qualquer trim ou lowercase feito no schema é descartado, e tudo o que o schema removia volta a aparecer.

3. Ela retorna result.error.issues sem tratamento. Os objetos de issue do Zod carregam detalhes internos, incluindo o tipo esperado e a restrição que falhou, e são formatados para código, não para pessoas. Um cliente precisa de pares campo-e-mensagem.

Existe uma quarta coisa que não é um bug nessa rota, mas vira um em escala maior: tudo isso vive dentro do handler, então a próxima rota ganha uma cópia, e as cópias vão se desalinhando com o tempo.

Para onde isso vai a seguir

O plano está definido. Uma verificação na frente do handler, um 400 com erros úteis quando falha, dados processados anexados à requisição quando passa.

Validation middleware constrói isso: uma função que aceita qualquer schema e retorna algo que o Express pode colocar na frente de qualquer rota.