Dia 5 — Validação de entrada em profundidade

Informatica · Conteudo · publicado em 30/09/2026
Dia 5 de 15

Validação de entrada em profundidade

Aula 1

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:

ChecagemRegraCódigo
obrigatórioo campo tem conteúdo?!nm depois do trim
tamanho mínimo e máximocabe no limite dos dois lados?nm.length < 1, nm.length > 40
tipoo 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 toLowerCase antes do teste de e-mail, o teste roda sobre o texto já normalizado; com trim depois do Number, o Number('') já decidiu que vazio é zero. A ordem é validar o dado cru, depois formatar.

Campo opcional é o caso que mais gera bug: nm_email vazio pode ser '', null ou a string 'null' vinda de um formulário que converteu tudo. ?? '' trata o ausente; || '' trata o 0 como se fosse vazio — o mesmo bug do desconto do dia 3, agora em campo de texto.

O required e o type="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)
Aula 2

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 parse devolve o mesmo objeto que recebeu, a normalização continua espalhada pelo código e o esquema é só um if mais organizado. Devolver o dado transformado é o que centraliza.

Campo opcional precisa de regra explícita: .optional() no zod, e no esquema manual um bruto === undefined que 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_LONG num esquema significa que o limite do VARCHAR e 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)