Dia 5 — Validação de entrada em profundidade
Validar tipo, formato e tamanho
A validação é no servidor, não no formulário
O formulário do 1º trimestre já impedia campo vazio antes do envio. Isso não é validação: protege o envio, não o dado que já chegou. Qualquer curl na URL ignora o formulário inteiro.
Validação de verdade é a que roda no servidor, antes do INSERT. Ela existe porque o dado que chega pode vir de um formulário, de um app, de um script de integração ou de alguém com curl na mão — e o banco não sabe a diferença.
Três perguntas, três tipos de regra
Validar entrada é decidir, campo a campo, se o dado que chegou pode ser gravado — e a decisão sai do servidor, não do formulário. A regra tem três perguntas, e o tipo do campo é a primeira delas:
| Checagem | Regra | Código |
|---|---|---|
| obrigatório | o campo tem conteúdo? | !nm depois do trim |
| tamanho mínimo e máximo | cabe no limite dos dois lados? | nm.length < 1, nm.length > 40 |
| tipo | o número é número? | Number.isInteger(Number(x)) |
O detalhe do trim vem primeiro de propósito: espaço em branco é ausência. Sem trim, " " tem comprimento 3, passa na regra de tamanho e só é pego na de obrigatório — tarde demais para a regra que dependia do comprimento.
O par de tamanho é o que fecha a porta dos dois lados. O tamanho mínimo impede o vazio que o trim pegaria mesmo; o tamanho máximo impede o nome de 60 letras que cabe em VARCHAR(255) e não cabe em VARCHAR(40). E-mail válido é a terceira checagem, com a sua própria função e o seu próprio regex de e-mail — que é assunto da seção seguinte.
Number() engana em três maneiras
Number('') // 0 Number(' ') // 0 Number('30abc') // NaN Number('12') // 12
Number('') devolver 0 é o que faz campo vazio passar como número válido. Number.isInteger separa os casos: Number.isInteger(Number('')) é false, mesmo com a comparação numérica passando.
E a ordem dos else if importa. A guarda de NaN vem antes da comparação de faixa, porque comparar com NaN devolve false nos dois sentidos — o else if de faixa seria tomado como se o número fosse pequeno demais, e o erro apontaria para a regra errada.
Uma regra por campo, e o campo que não tem @
O erro mais comum ao escrever validação é grudar a regra de e-mail no campo de texto:
// ERRADO: `teclado` nao tem @ e nunca vai ter if (!/^[a-z0-9._-]+@...$/.test(nm_item)) erros.push('formato invalido');
Isso reprova produto legítimo. A solução é uma função por campo, cada uma com a sua regra:
validarProduto({ nm_item, qtd }) // obrigatório e tamanho validarEmail(nm_email) // @, dominio com ponto, espaco
O exemplo do dia mostra as duas lado a lado, e a diferença aparece no primeiro caso de e-mail: [email protected] entra e sai como [email protected], porque o toLowerCase vem antes do teste. Normalizar depois de validar não funciona: o teste roda sobre o texto original, com a maiúscula que ele não espera.
validarEmail decide o que é e-mail válido contando o que o exemplo mede: exatamente um @, parte antes do @ não vazia, domínio com ponto, e o final do domínio sem caractere estranho. É a forma legível, e ela reprova ana@localhost com a mensagem que aponta o problema. A outra forma, o regex de e-mail, é uma linha só — /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/ — e cobre mais formato com menos linha. A escolha é entre a regex, que é curta e difícil de adaptar, e a decomposição, que é longa e diz exatamente qual regra o dado quebrou. A resposta 400 com o campo e a mensagem é o que faz as duas valerem a mesma coisa.
A resposta 400 devolve a lista inteira
Cada regra empurra o problema e segue — sem return. Quem cadastrou errou o nome e a quantidade vê as duas mensagens de uma vez:
{
"erro": "validacao falhou",
"quantidade": 2,
"campos": ["nm_item", "qtd"],
"detalhe": [
{ "campo": "nm_item", "erro": "campo obrigatorio" },
{ "campo": "qtd", "erro": "precisa ser numero inteiro", "recebido": "\"dois\"" }
]
}
Esse corpo é a resposta 400 com erros, e ele é montado por respostaDeErro(erros). Os dois campos do topo — campos e detalhe — são a mesma lista de erros em dois formatos: o primeiro diz quais campos falharam, para o cliente pintar em vermelho; o segundo diz como cada um falhou. É o erro de validação por campo resolvido: cada item da lista carrega o nome do campo junto da mensagem, e o cliente não precisa adivinhar a qual campo a frase "campo obrigatorio" se refere.
O recebido é o que fecha o ciclo: o cliente sabe qual valor chegou e o que era esperado. Compare com a resposta que devolve só a primeira falha:
resposta que devolve so a primeira falha: {"erro":"nm_item: campo obrigatorio"} o cliente corrige um campo, envia de novo, e toma o mesmo erro: 3 tentativas
A função também devolve sempre a mesma forma — valido e erros, com e sem erro. Quem chama nunca precisa perguntar "deu erro?". A variante que devolve { valido: false } no erro e a lista no sucesso obriga todo mundo a checar os dois formatos.
O banco é o piso, não a parede
Com a coluna em VARCHAR(40) NOT NULL, o que acontece sem validação? O exemplo tenta gravar os dois dados ruins:
INSERT nome vazio -> passou, id 1 (o banco aceitou) INSERT nome 60 letras -> recusado pelo banco: ER_DATA_TOO_LONG linhas com dado invalido gravadas: 1 de 2 tentativas id 1 "" qtd=0
O nome de 60 letras foi recusado com ER_DATA_TOO_LONG. O nome vazio com qtd 0 passou: string vazia cabe em VARCHAR(40) e zero cabe em INT.
A lição é a direção da proteção: o banco protege do que é grande demais, e não do que é errado demais. Ele sabe o tamanho do dado, não o significado.
E a mensagem dele chega assim:
Data too long for column 'nm_item' at row 1
Em inglês, sem dizer qual campo o usuário deve corrigir, e com o nome da coluna do banco em vez do nome do campo na tela. ER_DATA_TOO_LONG é o erro.code que a aplicação precisa comparar — erro.message sozinho não serve para decidir nada.
Passando por validação, os mesmos dois dados são recusados antes do INSERT:
RECUSA nome vazio -> nm_item: campo obrigatorio; qtd: precisa ser maior ou igual a 1 RECUSA nome 60 letras -> nm_item: no maximo 40 caracteres; qtd: precisa ser maior ou igual a 1 aceitos: 0, recusados: 2 — o invalido nao chegou no INSERT linhas invalidas depois do caminho validado: 0
Validar depois de formatar inverte a ordem e quebra tudo. Com
toLowerCaseantes do teste de e-mail, o teste roda sobre o texto já normalizado; comtrimdepois doNumber, oNumber('')já decidiu que vazio é zero. A ordem é validar o dado cru, depois formatar.
Campo opcional é o caso que mais gera bug:
nm_emailvazio pode ser'',nullou a string'null'vinda de um formulário que converteu tudo.?? ''trata o ausente;|| ''trata o0como se fosse vazio — o mesmo bug do desconto do dia 3, agora em campo de texto.
O
requirede otype="email"do navegador continuam válidos como primeira barreira: eles melhoram a experiência de quem está preenchendo. Eles não substituem a validação no servidor, porque não protegem o dado que já chegou.
Exemplo
'use strict'; // Exemplo da aula 1 do dia 5: validar tipo, formato e tamanho — e o custo de // deixar isso de fora. // // A funcao `validarProduto` devolve SEMPRE a mesma forma: um objeto com `valido` // e `erros`. Quem chama nunca precisa perguntar se o erro veio, e o chamador // pode listar todos os problemas de uma vez em vez de um por tentativa. // // O exemplo compara o caminho validado com o caminho sem validacao, gravando // as duas vezes no MySQL: e assim que a diferenca vira numero, e nao // opiniao. `INSERT` sem `VALIDATE` no schema e a razao do segundo bloco. const { createConnection } = require('mysql2/promise'); // ------------------------------------------------------------- a validação // Cada regra empurra o problema e SEGUE: quem cadastrou errou o nome e a // quantidade, ve as duas mensagens de uma vez. function validarProduto(dados) { const erros = []; // --- tipo e obrigatório // `String(dados.nm_item ?? '')` normaliza antes de medir. Com `undefined`, // `String(undefined)` seria a palavra "undefined", e o campo vazio passaria // como preenchido. const nm = String(dados.nm_item ?? '').trim(); const qtdBruto = dados.qtd; if (!nm) { erros.push({ campo: 'nm_item', erro: 'campo obrigatorio' }); } // --- tamanho if (nm.length > 40) { erros.push({ campo: 'nm_item', erro: 'no maximo 40 caracteres', recebido: nm.length, limite: 40, }); } // --- tipo do numero // `Number.isInteger(Number(x))` e a unica forma confiavel: `Number('')` vale // 0, `Number('30abc')` vale NaN. O `Number.isInteger` distingue numero de // lixo e a comparacao de faixa nao distingue nada. const qtd = Number(qtdBruto); if (!Number.isInteger(qtd)) { erros.push({ campo: 'qtd', erro: 'precisa ser numero inteiro', recebido: JSON.stringify(qtdBruto), }); } else if (qtd < 1) { erros.push({ campo: 'qtd', erro: 'precisa ser maior ou igual a 1', recebido: qtd }); } else if (qtd > 9999) { erros.push({ campo: 'qtd', erro: 'no maximo 9999', recebido: qtd }); } return { valido: erros.length === 0, erros, limpo: { nm_item: nm, qtd: Number.isInteger(qtd) ? qtd : 0 }, }; } // O e-mail tem regra propria, e e por isso que fica em funcao separada: o // campo de nome do produto NAO passa por aqui. Um unico validador com a regra // de e-mail grudada no campo de texto reprova item legitimo — `teclado` nao // tem @ e nunca vai ter. function validarEmail(texto) { const erros = []; const email = String(texto ?? '').trim().toLowerCase(); if (!email) { erros.push({ campo: 'nm_email', erro: 'campo obrigatorio' }); return { valido: false, erros, limpo: email }; } if (email.length > 80) { erros.push({ campo: 'nm_email', erro: 'no maximo 80 caracteres', recebido: email.length }); } // Um `@` so: dois `@` nao formam endereco. const partes = email.split('@'); if (partes.length !== 2) { erros.push({ campo: 'nm_email', erro: 'precisa ter exatamente um @' }); } else { const [local, dominio] = partes; if (!local) erros.push({ campo: 'nm_email', erro: 'parte antes do @ esta vazia' }); if (!dominio.includes('.')) { // `ana@localhost` nao e endereco de entrega, e o teste do ponto barra. erros.push({ campo: 'nm_email', erro: 'dominio precisa ter ponto' }); } const depoisDoPonto = dominio.split('.').pop(); if (!/^[a-z]{2,}$/.test(depoisDoPonto)) { erros.push({ campo: 'nm_email', erro: 'final do dominio invalido' }); } } if (/\s/.test(email)) { // O espaco e erro de digitacao, e nao aparece no teste do dominio. erros.push({ campo: 'nm_email', erro: 'nao pode conter espaco' }); } return { valido: erros.length === 0, erros, limpo: email }; } // A resposta 400 que a API devolve: sempre lista, nunca a primeira falha. function respostaDeErro(erros) { return { status: 400, corpo: { erro: 'validacao falhou', quantidade: erros.length, campos: erros.map((e) => e.campo), detalhe: erros, }, }; } async function main() { const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); await c.query(` CREATE TABLE IF NOT EXISTS tb_valida ( id INT AUTO_INCREMENT PRIMARY KEY, nm_item VARCHAR(40) NOT NULL, qtd INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query('TRUNCATE TABLE tb_valida'); // ------------------------------------------------ os casos, um por um console.log('--- 1. cada tipo de erro, com o campo que o causou ---'); const casos = [ { rotulo: 'tudo certo', dado: { nm_item: 'teclado', qtd: 2 } }, { rotulo: 'nome vazio', dado: { nm_item: ' ', qtd: 1 } }, { rotulo: 'nome ausente', dado: { qtd: 1 } }, { rotulo: 'nome longo', dado: { nm_item: 'x'.repeat(45), qtd: 1 } }, { rotulo: 'qtd como texto', dado: { nm_item: 'mouse', qtd: 'dois' } }, { rotulo: 'qtd zero', dado: { nm_item: 'mouse', qtd: 0 } }, { rotulo: 'qtd negativo', dado: { nm_item: 'mouse', qtd: -3 } }, { rotulo: 'qtd absurdo de grande', dado: { nm_item: 'mouse', qtd: 99999 } }, { rotulo: 'varios erros juntos', dado: { nm_item: '', qtd: 'dois' } }, ]; for (const caso of casos) { const r = validarProduto(caso.dado); const marca = r.valido ? 'OK ' : 'BLOQ.'; const campos = r.erros.length ? r.erros.map((e) => e.campo + '/' + e.erro).join(' | ') : 'nenhum erro'; console.log(marca + ' | ' + caso.rotulo.padEnd(24) + ' ' + campos); } // ------------------------------- a resposta 400 que o cliente recebe console.log('\n--- 2. a resposta 400 completa (dois erros de uma vez) ---'); const problematico = validarProduto({ nm_item: '', qtd: 'dois' }); const resposta = respostaDeErro(problematico.erros); console.log('status: ' + resposta.status); console.log('corpo: ' + JSON.stringify(resposta.corpo, null, 1) .split('\n').join('\n')); // Compare com a resposta ruim: a que devolve so a primeira falha. const soPrimeiro = { status: 400, corpo: { erro: problematico.erros[0].campo + ': ' + problematico.erros[0].erro, }, }; console.log('\nresposta que devolve so a primeira falha: ' + JSON.stringify(soPrimeiro.corpo)); console.log('o cliente corrige um campo, envia de novo, e toma o mesmo erro: 3 tentativas'); // ---------------------------------------------------- o e-mail, campo a campo console.log('\n--- 2b. o mesmo formato, agora no e-mail ---'); const emails = [ { rotulo: 'email valido', texto: '[email protected]' }, { rotulo: 'sem arroba', texto: 'ana.exemplo.com' }, { rotulo: 'arroba duplo', texto: 'ana@[email protected]' }, { rotulo: 'dominio sem ponto', texto: 'ana@localhost' }, { rotulo: 'final invalido', texto: '[email protected]' }, { rotulo: 'espaco no meio', texto: 'ana @exemplo.com' }, { rotulo: 'parte antes vazia', texto: '@exemplo.com' }, { rotulo: 'vazio', texto: '' }, ]; for (const caso of emails) { const r = validarEmail(caso.texto); console.log((r.valido ? 'OK ' : 'BLOQ.') + ' | ' + caso.rotulo.padEnd(22) + (r.valido ? 'limpo=' + r.limpo : r.erros.map((e) => e.erro).join(' | '))); } console.log('o primeiro caso mostra o toLowerCase: [email protected] entra e sai como [email protected]'); // -------------------------- o que acontece sem validar: dado no banco console.log('\n--- 3. sem validacao, o que o banco aceita ---'); const sujos = [ { rotulo: 'nome vazio', nm_item: '', qtd: 0 }, { rotulo: 'nome 60 letras', nm_item: 'y'.repeat(60), qtd: -1 }, ]; // Com a coluna em VARCHAR(40) NOT NULL, o proprio banco recusa o que nao // cabe. E o piso, nao a solucao: a mensagem chega em ingles, e o qtd // negativo passa reto porque INT aceita negativo. // `execute` e prepared statement: um INSERT com varias linhas e comando // composto e recusado. Sao dois, um por vez. for (const s of sujos) { try { const [r] = await c.execute( 'INSERT INTO tb_valida (nm_item, qtd) VALUES (?, ?)', [s.nm_item, s.qtd]); console.log('INSERT ' + s.rotulo.padEnd(16) + ' -> passou, id ' + r.insertId + ' (o banco aceitou)'); } catch (erro) { // O par: `console.error` vai para o terminal, `console.log` tambem vai // para a pagina. A aula ensina o erro, entao o erro precisa aparecer. console.error('INSERT ' + s.rotulo + ': ' + erro.code + ' - ' + erro.message); console.log(' INSERT ' + s.rotulo.padEnd(16) + ' -> recusado pelo banco: ' + erro.code); console.log(' mensagem do banco vem em ingles e nao diz qual campo o usuario deve corrigir'); } } // O nome de 60 letras foi recusado por `ER_DATA_TOO_LONG`, e o nome vazio // com qtd 0 passou. A contagem sai da consulta, e nao de conta de cabeca. const [invalidas] = await c.query( 'SELECT COUNT(*) AS n FROM tb_valida WHERE nm_item = ? OR qtd <= 0', ['']); console.log('\nlinhas com dado invalido gravadas: ' + invalidas[0].n + ' de ' + sujos.length + ' tentativas'); const [todos] = await c.query('SELECT id, nm_item, qtd FROM tb_valida ORDER BY id'); for (const t of todos) { console.log(' id ' + t.id + ' "' + t.nm_item + '" qtd=' + t.qtd); } console.log('o nome vazio cabe em VARCHAR(40) e o qtd negativo cabe em INT:'); console.log('o banco protege do que e grande demais, e nao do que e errado demais'); await c.query('DELETE FROM tb_valida WHERE nm_item = ? OR qtd <= 0', ['']); // -------------------------------- o caminho validado, com numeros console.log('\n--- 4. o mesmo dado, agora passando pela validacao ---'); let aceitos = 0; let recusados = 0; for (const s of sujos) { const r = validarProduto({ nm_item: s.nm_item, qtd: s.qtd }); if (!r.valido) { recusados++; console.log('RECUSA ' + s.rotulo.padEnd(16) + ' -> ' + r.erros.map((e) => e.campo + ': ' + e.erro).join('; ')); continue; } await c.execute('INSERT INTO tb_valida (nm_item, qtd) VALUES (?, ?)', [r.limpo.nm_item, r.limpo.qtd]); aceitos++; } console.log('aceitos: ' + aceitos + ', recusados: ' + recusados + ' — o invalido nao chegou no INSERT'); // A prova final: nada invalido sobrou. const [final] = await c.query( 'SELECT COUNT(*) AS n FROM tb_valida WHERE nm_item = ? OR qtd <= 0', ['']); console.log('linhas invalidas depois do caminho validado: ' + final[0].n); const [total] = await c.query('SELECT COUNT(*) AS n FROM tb_valida'); console.log('linhas na tabela: ' + total[0].n + ' (o invalido foi recusado antes do INSERT)'); await c.end(); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 1. cada tipo de erro, com o campo que o causou ---
OK | tudo certo nenhum erro
BLOQ. | nome vazio nm_item/campo obrigatorio
BLOQ. | nome ausente nm_item/campo obrigatorio
BLOQ. | nome longo nm_item/no maximo 40 caracteres
BLOQ. | qtd como texto qtd/precisa ser numero inteiro
BLOQ. | qtd zero qtd/precisa ser maior ou igual a 1
BLOQ. | qtd negativo qtd/precisa ser maior ou igual a 1
BLOQ. | qtd absurdo de grande qtd/no maximo 9999
BLOQ. | varios erros juntos nm_item/campo obrigatorio | qtd/precisa ser numero inteiro
--- 2. a resposta 400 completa (dois erros de uma vez) ---
status: 400
corpo: {
"erro": "validacao falhou",
"quantidade": 2,
"campos": [
"nm_item",
"qtd"
],
"detalhe": [
{
"campo": "nm_item",
"erro": "campo obrigatorio"
},
{
"campo": "qtd",
"erro": "precisa ser numero inteiro",
"recebido": "\"dois\""
}
]
}
resposta que devolve so a primeira falha: {"erro":"nm_item: campo obrigatorio"}
o cliente corrige um campo, envia de novo, e toma o mesmo erro: 3 tentativas
--- 2b. o mesmo formato, agora no e-mail ---
OK | email valido [email protected]
BLOQ. | sem arroba precisa ter exatamente um @
BLOQ. | arroba duplo precisa ter exatamente um @
BLOQ. | dominio sem ponto dominio precisa ter ponto
BLOQ. | final invalido final do dominio invalido
BLOQ. | espaco no meio nao pode conter espaco
BLOQ. | parte antes vazia parte antes do @ esta vazia
BLOQ. | vazio campo obrigatorio
o primeiro caso mostra o toLowerCase: [email protected] entra e sai como [email protected]
--- 3. sem validacao, o que o banco aceita ---
INSERT nome vazio -> passou, id 1 (o banco aceitou)
INSERT nome 60 letras -> recusado pelo banco: ER_DATA_TOO_LONG
mensagem do banco vem em ingles e nao diz qual campo o usuario deve corrigir
linhas com dado invalido gravadas: 1 de 2 tentativas
id 1 "" qtd=0
o nome vazio cabe em VARCHAR(40) e o qtd negativo cabe em INT:
o banco protege do que e grande demais, e nao do que e errado demais
--- 4. o mesmo dado, agora passando pela validacao ---
RECUSA nome vazio -> nm_item: campo obrigatorio; qtd: precisa ser maior ou igual a 1
RECUSA nome 60 letras -> nm_item: no maximo 40 caracteres; qtd: precisa ser maior ou igual a 1
aceitos: 0, recusados: 2 — o invalido nao chegou no INSERT
linhas invalidas depois do caminho validado: 0
linhas na tabela: 0 (o invalido foi recusado antes do INSERT)
Validação como camada, com biblioteca
O esquema é a validação escrita uma vez
A aula 1 escrevia a regra com if, campo a campo, dentro de uma função. Funciona — até aparecer a segunda rota, o script de importação e o teste, que repetem a mesma regra e divergem no primeiro ajuste esquecido.
Um esquema de validação é a regra escrita como declaração, e o objeto inteiro é o schema:
const ItemSchema = { nm_item: t.string({ trim: true, min: 1, max: 40 }), nm_email: t.email(), qtd: t.int({ min: 1, max: 9999 }), ativo: t.bool(), };
O que muda não é a quantidade de regra: é que ela passa a ter um dono. A rota, o repositório e o teste leem todos do mesmo objeto, e ninguém reescreve a condição.
O exemplo do dia implementa o essencial do zod com node:crypto e sem dependência nova, porque o que importa aqui é o formato do esquema e o contrato do safeParse — não a biblioteca. Validar com biblioteca e validar com if dá o mesmo resultado no mesmo dado; o que muda é quem mantém a regra depois.
parse lança, safeParse devolve
Os dois caminhos, e a escolha entre eles é feita pelo chamador, não por gosto:
schema.safeParse(dados) // devolve { success: true, data } ou { success: false, error } schema.parse(dados) // devolve data, ou LANÇA
safeParse nunca lança. É o que permite tratar a falha sem try/catch:
success : false data : (ausente) error.issues : nm_item campo obrigatorio nm_email formato de e-mail invalido qtd precisa ser numero inteiro (recebido "\"dois\"") ativo precisa ser booleano (recebido "\"talvez\"") 4 problemas de 4 campos: o esquema nao para no primeiro e data esta AUSENTE junto com success: false — quem chama nao tem como ler o dado
A última linha é uma garantia de tipo: com success: false, o campo data não existe. Não há como ler result.data.nm_item sem checar, porque o valor não está lá. Quem escreve em TypeScript recebe erro de compilação nesse acesso.
O parse é o atalho do mesmo caminho, com o throw no lugar do retorno, e a validação em uma linha é essa chamada. O exemplo mostra o erro de schema que ele produz:
erro.code : ER_VALIDACAO message : nm_item: campo obrigatorio issues : 4 (a lista completa vem anexada)
O message tem um erro — o primeiro —, mas a lista completa vem anexada no erro.issues. É assim que o catch do servidor consegue responder 400 com todos os problemas mesmo tendo usado parse.
O esquema também normaliza
O safeParse bem-sucedido devolve o dado pronto para o banco, e o exemplo mostra a transformação:
entrada crua : {"nm_item":" teclado ","nm_email":"[email protected]","qtd":"2","ativo":"true"} success : true data : {"nm_item":"teclado","nm_email":"[email protected]","qtd":2,"ativo":true}
Quatro conversões numa passagem: espaço das pontas removido, e-mail em minúsculas, "2" virou 2, "true" virou true.
O ponto que importa: o data é o único que vai para o INSERT. O objeto original não volta. É por isso que dá para confiar que o que chegou no banco está no formato esperado — não há caminho em que o dado cru escape.
Em zod isso se escreve como .trim(), .toLowerCase() e .transform() encadeados, e o z.coerce.number() faz a conversão de tipo.
Onde a validação mora na arquitetura
A camada decide onde a validação acontece, e a escolha não é decorativa.
// No repositorio: o INSERT nao ve dado invalido porque nao ha caminho async criar(dados) { const item = parse(ItemSchema, dados); const [r] = await c.execute('INSERT INTO ... VALUES (?, ?, ?, ?)', [...]); return r.insertId; }
Validar no repositório garante que nenhuma escrita passe sem passar pelo esquema — inclusive a que vier de um script de importação escrito à pressa. Validar na rota deixa o repositório chamável por qualquer código, e o INSERT passa a ter duas portas de entrada, uma delas sem portão. Dados inválidos nunca chegam ao banco é a propriedade que decide onde a validação mora.
O exemplo passa pela rota com o corpo cru, e o repositório recusa:
criar com dado valido -> id 1 repositorio recusou: ER_VALIDACAO o INSERT nem foi montado: 3 campo(s) barrado(s) antes do SQL linhas no banco: 1 id 1 mouse bruno@exemplo.com qtd=5 ativo=true (number/boolean) um item so: o invalido nunca chegou perto do INSERT
O que chegou ao banco tem qtd como number e ativo como boolean — e a listagem reconverte na leitura porque TINYINT(1) volta como 0 ou 1 do driver.
A propriedade que a biblioteca pura tem e a reimplementação não
zod inferi o tipo a partir do esquema. const item = SchemaItem.parse(corpo) devolve algo que o compilador já sabe ser { nm_item: string, qtd: number }, sem ninguém escrever o tipo — é o tipo inferido do schema, e ele vem do próprio esquema, não de uma declaração separada que alguém esquece de atualizar.
type Item = z.infer<typeof ItemSchema>;
Com JavaScript puro, essa propriedade vira JSDoc e não é verificada — é comentário, e comentário não impede nada. O argumento real do zod está em TypeScript: não é escrever menos regra, é o compilador recusar dado inválido em qualquer lugar do código, inclusive longe do esquema.
É o que a aula do dia 25 do eixo retoma: a mesma validação, agora com o compilador participating.
Esquema que só valida e perde metade do ganho. Se o
parsedevolve o mesmo objeto que recebeu, a normalização continua espalhada pelo código e o esquema é só umifmais organizado. Devolver o dado transformado é o que centraliza.
Campo opcional precisa de regra explícita:
.optional()nozod, e no esquema manual umbruto === undefinedque não vira erro. É a diferença entre "campo ausente" e "campo vazio", e os dois precisam de tratamento diferente.
Esquema que vaza erro do banco continua sendo um problema de camada.
ER_DATA_TOO_LONGnum esquema significa que o limite doVARCHARe da aplicação foram escritos em lugares diferentes — e o próximo a mexer em um deles vai quebrar o outro.
Exemplo
'use strict'; // Exemplo da aula 2 do dia 5: validacao como camada, com esquema. // // O `zod` nao esta instalado neste material, entao o exemplo implementa o // minimo do mesmo contrato — `schema.parse()` e `schema.safeParse()` — com // ~40 linhas. O que importa nao e a biblioteca: e o FORMATO do esquema e o // que `safeParse` devolve. // // E o `zod` puro tem uma propriedade que a implementacao nao reproduz: o tipo // do dado e INFERIDO do esquema, entao a rota sabe o que o `parse` devolve sem // repetir a regra. Com JS puro, esse trecho e a `JSDoc`: // // /** @typedef {z.infer<typeof SchemaItem>} Item */ // /** @type {Item} */ // const item = SchemaItem.parse(corpo); // so o TS knows o tipo // // Esse e o argumento real de usar `zod` em TypeScript: nao e escrever menos // regra, e o compilador parar de aceitar dado invalido em qualquer lugar do // codigo. const { createConnection } = require('mysql2/promise'); // ================================================= um mini esquema, no estilo zod // Um no de esquema guarda a regra e sabe se conformar. `safeParse` NUNCA // lanca: devolve `{ success: true, data }` ou `{ success: false, error }`. E // o que permite tratar a falha sem `try`/`catch`. // // O `zod` puro tem uma propriedade que esta implementacao nao reproduz: o tipo // do dado e INFERIDO do esquema, entao a rota sabe o que o `parse` devolve sem // repetir a regra. Com JS puro, esse trecho e a `JSDoc`: // // /** @typedef {z.infer<typeof SchemaItem>} Item */ // /** @type {Item} */ // const item = SchemaItem.parse(corpo); // so o TS sabe o tipo // // Esse e o argumento real de usar `zod` em TypeScript: nao e escrever menos // regra, e o compilador parar de aceitar dado invalido em qualquer lugar do // codigo. // // O email declara que e minusculo ANTES de conferir. E o `normaliza` que // roda DEPOIS do tipo confere e ANTES da regra de formato: e a ordem da // aula 1 (validar o dado cru, depois formatar) escrita no esquema. const t = { string: (regras = {}) => ({ tipo: 'string', // O `normaliza` e o que transforma: o `zod` faz isso com `.trim()`, // `.toLowerCase()` e `.transform()` encadeados no esquema. normaliza: (v) => { let saida = regras.trim ? v.trim() : v; if (regras.minusculo) saida = saida.toLowerCase(); return saida; }, confere(bruto) { if (typeof bruto !== 'string') { return { ok: false, mensagem: 'precisa ser texto' }; } const valor = this.normaliza(bruto); if (regras.min && valor.length < regras.min) { return { ok: false, mensagem: 'no minimo ' + regras.min + ' caracteres', recebido: valor.length, }; } if (regras.max && valor.length > regras.max) { return { ok: false, mensagem: 'no maximo ' + regras.max + ' caracteres', recebido: valor.length, }; } if (regras.depois && !regras.depois(valor)) { return { ok: false, mensagem: regras.mensagem || 'formato invalido' }; } return { ok: true, valor }; }, }), int: (regras = {}) => ({ tipo: 'int', confere(bruto) { const valor = Number(bruto); if (!Number.isInteger(valor)) { return { ok: false, mensagem: 'precisa ser numero inteiro', recebido: JSON.stringify(bruto), }; } if (regras.min !== undefined && valor < regras.min) { return { ok: false, mensagem: 'minimo ' + regras.min, recebido: valor }; } if (regras.max !== undefined && valor > regras.max) { return { ok: false, mensagem: 'maximo ' + regras.max, recebido: valor }; } return { ok: true, valor }; }, }), email: () => t.string({ trim: true, minusculo: true, min: 3, max: 80, mensagem: 'formato de e-mail invalido', depois: (v) => { const partes = v.split('@'); return partes.length === 2 && partes[1].includes('.') && /^[a-z]{2,}$/.test(partes[1].split('.').pop()); }, }), }; // `nm_item` com `min: 1` e `trim`: o espaco em branco vira string vazia depois // do trim, e o `min` acusa. E o mesmo `trim` do 1º trimestre, agora escrito // como declaracao de esquema em vez de `if`. const ItemSchema = { nm_item: t.string({ trim: true, min: 1, max: 40 }), nm_email: t.email(), qtd: t.int({ min: 1, max: 9999 }), ativo: { tipo: 'bool', confere: (bruto) => { if (typeof bruto === 'boolean') return { ok: true, valor: bruto }; if (bruto === 'true' || bruto === 1) return { ok: true, valor: true }; if (bruto === 'false' || bruto === 0) return { ok: true, valor: false }; return { ok: false, mensagem: 'precisa ser booleano', recebido: JSON.stringify(bruto) }; } }, }; // O validador do esquema. Percorre TODOS os campos e acumula os erros — nao // para no primeiro. E o mesmo contrato do `safeParse` do zod. function safeParse(schema, dados) { const erros = []; const saida = {}; for (const [campo, regra] of Object.entries(schema)) { const bruto = dados[campo]; // Campo ausente e diferente de campo vazio: `undefined` nao e string, e // a regra de tipo acusa. E por isso que o `tipo` vem antes da `trim`. if (bruto === undefined) { erros.push({ campo, erro: 'campo obrigatorio' }); continue; } const r = regra.confere(bruto); if (r.ok) saida[campo] = r.valor; else erros.push({ campo, erro: r.mensagem, ...(r.recebido !== undefined ? { recebido: r.recebido } : {}) }); } return erros.length === 0 ? { success: true, data: saida } : { success: false, error: { issues: erros } }; } // `parse` e o atalho que lanca. Quem chama quer o dado, e nao quer tratar // erro — entao o erro e para tras, e o `catch` do servidor pega. function parse(schema, dados) { const r = safeParse(schema, dados); if (!r.success) { const primeiro = r.error.issues[0]; const erro = new Error(primeiro.campo + ': ' + primeiro.erro); erro.code = 'ER_VALIDACAO'; erro.issues = r.error.issues; throw erro; } return r.data; } // ============================================================ camada de dados // Repositorio com o esquema na porta. O `INSERT` nao ve dado invalido porque // nao existe caminho que chegue ate ele com dado invalido. function criarRepositorio(c) { return { async criar(dados) { // O unico `parse` do caminho. Tudo que vier para dentro ja passou. const item = parse(ItemSchema, dados); const [r] = await c.execute( 'INSERT INTO tb_esquema (nm_item, nm_email, qtd, ativo) VALUES (?, ?, ?, ?)', [item.nm_item, item.nm_email, item.qtd, item.ativo ? 1 : 0]); return r.insertId; }, async listar() { const [linhas] = await c.query('SELECT * FROM tb_esquema ORDER BY id'); return linhas.map((l) => ({ ...l, qtd: Number(l.qtd), ativo: Boolean(l.ativo), })); }, }; } async function main() { const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); await c.query(` CREATE TABLE IF NOT EXISTS tb_esquema ( id INT AUTO_INCREMENT PRIMARY KEY, nm_item VARCHAR(40) NOT NULL, nm_email VARCHAR(80) NOT NULL, qtd INT NOT NULL, ativo TINYINT(1) NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query('TRUNCATE TABLE tb_esquema'); const repo = criarRepositorio(c); // ---------------------------------------------- safeParse: o caminho feliz console.log('--- 1. safeParse: o que o esquema devolve ---'); const bom = { nm_item: ' teclado ', nm_email: '[email protected]', qtd: '2', ativo: 'true' }; const r1 = safeParse(ItemSchema, bom); console.log('entrada crua : ' + JSON.stringify(bom)); console.log('success : ' + r1.success); console.log('data : ' + JSON.stringify(r1.data)); console.log('o esquema ja devolveu o dado pronto para o banco:'); console.log(' nm_item com trim aplicado, nm_email em minusculo,'); console.log(' qtd convertido para number, ativo convertido para boolean'); console.log('o `data` e o unico que vai para o INSERT — o `bom` original nao volta'); // ------------------------------------------------- safeParse: com problema console.log('\n--- 2. safeParse: todos os problemas de uma vez ---'); const ruim = { nm_item: '', nm_email: 'ana@localhost', qtd: 'dois', ativo: 'talvez' }; const r2 = safeParse(ItemSchema, ruim); console.log('entrada crua : ' + JSON.stringify(ruim)); console.log('success : ' + r2.success); console.log('data : ' + (r2.data === undefined ? '(ausente)' : 'presente')); console.log('error.issues :'); for (const i of r2.error.issues) { console.log(' ' + i.campo.padEnd(9) + i.erro + (i.recebido !== undefined ? ' (recebido ' + JSON.stringify(i.recebido) + ')' : '')); } console.log(r2.error.issues.length + ' problemas de 4 campos: o esquema nao para no primeiro'); console.log('e data esta AUSENTE junto com success: false — quem chama nao tem como ler o dado'); // ----------------------------------------------------- parse: lanca de fato console.log('\n--- 3. parse: o atalho que lanca ---'); try { parse(ItemSchema, ruim); console.log('nao chegou aqui'); } catch (erro) { console.error('parse lancou: ' + erro.code + ' - ' + erro.message); console.log(' erro.code : ' + erro.code); console.log(' message : ' + erro.message); console.log(' issues : ' + erro.issues.length + ' (a lista completa vem anexada)'); console.log(' `parse` lanca; `safeParse` devolve. Escolha por isso, nao por gosto.'); } // ------------------------------------------ a camada: quem chama do schema? console.log('\n--- 4. a camada de validacao protege o repositorio ---'); const caminhoValido = { nm_item: 'mouse', nm_email: '[email protected]', qtd: 5, ativo: true }; const id1 = await repo.criar(caminhoValido); console.log('criar com dado valido -> id ' + id1); // A rota passa o corpo CRU. Quem valida e o repositorio, na entrada. try { await repo.criar({ nm_item: 'x'.repeat(50), nm_email: 'ninguem', qtd: 0, ativo: 1 }); console.log('nao chegou aqui'); } catch (erro) { console.error('repositorio recusou: ' + erro.code); console.log(' o INSERT nem foi montado: ' + erro.issues.length + ' campo(s) barrado(s) antes do SQL'); } // O que chegou ao banco: const gravados = await repo.listar(); console.log('\nlinhas no banco: ' + gravados.length); for (const g of gravados) { console.log(' id ' + g.id + ' ' + g.nm_item.padEnd(8) + ' ' + g.nm_email.padEnd(20) + ' qtd=' + g.qtd + ' ativo=' + g.ativo + ' (' + typeof g.qtd + '/' + typeof g.ativo + ')'); } console.log('um item so: o invalido nunca chegou perto do INSERT'); // ---------------------------------------------------- o ganho do esquema console.log('\n--- 5. o que o esquema compra sobre o if solto ---'); const campos = Object.keys(ItemSchema); console.log('campos declarados: ' + campos.length + ' (' + campos.join(', ') + ')'); console.log('regras escritas a mao na aula 1: obrigatorio, tamanho, tipo, faixa, e-mail'); console.log('aqui: uma declaracao por campo, e o esquema cuida do resto'); console.log('quem valida e sempre o mesmo codigo — a rota, o script e o teste'); console.log('e o tipo do dado pode ser INFERIDO do esquema (e o que o TS faz)'); await c.end(); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 1. safeParse: o que o esquema devolve ---
entrada crua : {"nm_item":" teclado ","nm_email":"[email protected]","qtd":"2","ativo":"true"}
success : true
data : {"nm_item":"teclado","nm_email":"[email protected]","qtd":2,"ativo":true}
o esquema ja devolveu o dado pronto para o banco:
nm_item com trim aplicado, nm_email em minusculo,
qtd convertido para number, ativo convertido para boolean
o `data` e o unico que vai para o INSERT — o `bom` original nao volta
--- 2. safeParse: todos os problemas de uma vez ---
entrada crua : {"nm_item":"","nm_email":"ana@localhost","qtd":"dois","ativo":"talvez"}
success : false
data : (ausente)
error.issues :
nm_item no minimo 1 caracteres (recebido 0)
nm_email formato de e-mail invalido
qtd precisa ser numero inteiro (recebido "\"dois\"")
ativo precisa ser booleano (recebido "\"talvez\"")
4 problemas de 4 campos: o esquema nao para no primeiro
e data esta AUSENTE junto com success: false — quem chama nao tem como ler o dado
--- 3. parse: o atalho que lanca ---
erro.code : ER_VALIDACAO
message : nm_item: no minimo 1 caracteres
issues : 4 (a lista completa vem anexada)
`parse` lanca; `safeParse` devolve. Escolha por isso, nao por gosto.
--- 4. a camada de validacao protege o repositorio ---
criar com dado valido -> id 1
o INSERT nem foi montado: 3 campo(s) barrado(s) antes do SQL
linhas no banco: 1
id 1 mouse [email protected] qtd=5 ativo=true (number/boolean)
um item so: o invalido nunca chegou perto do INSERT
--- 5. o que o esquema compra sobre o if solto ---
campos declarados: 4 (nm_item, nm_email, qtd, ativo)
regras escritas a mao na aula 1: obrigatorio, tamanho, tipo, faixa, e-mail
aqui: uma declaracao por campo, e o esquema cuida do resto
quem valida e sempre o mesmo codigo — a rota, o script e o teste
e o tipo do dado pode ser INFERIDO do esquema (e o que o TS faz)