Regex que só aceita número: por que "12a" ainda entra e como validar no servidor
Escreva a regex ancorada certa, separe quantidade, código com zeros e valor decimal, converta só depois de validar e prove com um curl que a API recusa "12a".
O campo de quantidade da loja tinha máscara: digitou letra, ela sumia. Mesmo assim, apareceu no banco um pedido com 1.000 unidades de um cabo que custa R$ 39,90. Ninguém digitou 1000. Um integrador mandou "1e3" direto na API, o servidor fez Number("1e3") e recebeu mil. No mesmo dia, um CEP 01310-100 foi gravado como o número 1310100 e a etiqueta de envio saiu sem o zero.
A resposta curta: a máscara do navegador é conforto, a validação é no servidor, e "só número" não é um tipo. Decida o que o campo é (quantidade inteira, código com zeros à esquerda ou valor decimal), valide o texto com uma regex ancorada no início e no fim, como ^[0-9]{1,3}$, e só converta depois de validar. Código como CEP e CPF continua sendo texto.
Por que a máscara não protege o dado
Tudo que roda no navegador está nas mãos de quem usa. A pessoa pode colar texto, desativar o JavaScript, usar uma extensão que preenche o formulário ou, mais comum, nem passar pelo formulário: um aplicativo, um integrador ou um script chama a API direto. A máscara não participa dessas conversas.
No servidor, a armadilha seguinte é a conversão permissiva. Em JavaScript, estas linhas são todas verdadeiras:
parseInt('12a') // 12 (para no primeiro caractere inválido)
Number('') // 0 (vazio vira zero)
Number(' 12 ') // 12 (espaços são ignorados)
Number('1e3') // 1000 (notação científica)
Number('0x10') // 16 (hexadecimal)
Se o servidor converte antes de validar, "12a" vira 12, campo vazio vira 0 e "1e3" vira 1000. Nenhum desses é o que a pessoa quis dizer, e nenhum gera erro. É por isso que a ordem importa: primeiro confira o formato do texto, depois converta.
A regex certa depende do que o campo é
"Só número" junta três coisas diferentes:
- Quantidade é um inteiro com faixa: de 1 a 999, por exemplo. Zero à esquerda não importa, sinal não é permitido.
- Código (CEP, CPF, matrícula, número de conta) é texto feito de dígitos. Tem tamanho fixo, o zero à esquerda faz parte, e você nunca faz conta com ele. Guarde como texto.
- Valor decimal (preço, peso) tem separador. No Brasil, a pessoa digita vírgula. Dinheiro vai para centavos inteiros, como explica dinheiro em centavos.
Âncoras: o erro que deixa "12a" passar
A regex [0-9]+ sem âncoras procura dígitos em algum lugar do texto. Em "12a" ela encontra "12" e responde que sim. Com ^ no início e $ no fim, o texto inteiro precisa ser dígito.
Há uma pegadinha entre linguagens. Em JavaScript, sem a flag m, o $ só casa no fim do texto. Em PHP (PCRE) e em Python, o $ também aceita uma quebra de linha final, então "12\n" passa. Em PHP, use \z no lugar de $ ou o modificador D; em Python, use re.fullmatch.
Outra: \d em JavaScript é só 0 a 9, mas em .NET e no re do Python 3 ele aceita dígitos de outros sistemas de escrita, como os arábico-índicos. Se você quer exatamente 0 a 9, escreva [0-9], que significa a mesma coisa em todas as linguagens.
Como validar no servidor, passo a passo
- Liste os campos e o tipo de cada um. Na loja: quantidade (inteiro de 1 a 999), CEP (8 dígitos, hífen opcional) e preço do produto no painel (até 7 dígitos, vírgula e até 2 casas).
- Normalize o mínimo. Converta para texto e tire espaços das pontas. Não "conserte" mais que isso: apagar letras em silêncio esconde erro de integração.
- Valide com regex ancorada. Uma por tipo, com tamanho.
- Converta só se passou, e confira a faixa depois de converter.
- Responda com erro claro (status 422 ou 400), dizendo o campo e o formato esperado, com a mesma frase que aparece na tela.
- Teste chamando a API direto, sem o formulário.
O código da loja, em Node.js com Express
const express = require('express');
const app = express();
function lerQuantidade(valor) {
const s = String(valor ?? '').trim();
if (!/^[0-9]{1,3}$/.test(s)) return null;
const n = Number(s);
return n >= 1 ? n : null;
}
function lerCep(valor) {
const s = String(valor ?? '').trim();
if (!/^[0-9]{5}-?[0-9]{3}$/.test(s)) return null;
return s.replace('-', ''); // continua texto: '01310100'
}
function lerPrecoEmCentavos(valor) {
const s = String(valor ?? '').trim();
const m = /^([0-9]{1,7})(?:,([0-9]{1,2}))?$/.exec(s);
if (!m) return null;
return Number(m[1]) * 100 + Number((m[2] || '0').padEnd(2, '0'));
}
app.post('/api/carrinho/itens', express.json(), (req, res) => {
const quantidade = lerQuantidade(req.body.quantidade);
if (quantidade === null) {
return res.status(422).json({
campo: 'quantidade',
erro: 'Informe um número inteiro de 1 a 999, sem ponto nem vírgula.'
});
}
const cep = lerCep(req.body.cep);
if (cep === null) {
return res.status(422).json({
campo: 'cep',
erro: 'Informe o CEP com 8 dígitos, como 01310-100.'
});
}
// grava quantidade (número) e cep (texto)
res.status(201).json({ quantidade, cep });
});
app.listen(3000);
O String(valor) no começo trata o caso em que o JSON traz número em vez de texto: 12 vira "12" e passa; 12.5 vira "12.5" e é recusado; true vira "true" e é recusado. Em lerPrecoEmCentavos, "49,9" vira 4990 e "49.90" é recusado, porque o painel combinou vírgula. Se o seu sistema aceita os dois, decida e documente.
A prova com curl
curl -s -X POST http://localhost:3000/api/carrinho/itens \
-H "Content-Type: application/json" \
-d '{"quantidade": "12a", "cep": "01310-100"}'
{"campo":"quantidade","erro":"Informe um número inteiro de 1 a 999, sem ponto nem vírgula."}
Repita com "1e3", "", "-1", "0" e " 12 ". Os quatro primeiros têm de ser recusados; o último passa, porque o espaço nas pontas foi removido de propósito. Transforme essa lista em teste automatizado: o post sobre regex testada com exemplo mostra como montar a lista do que deve passar e do que deve falhar.
E o formulário?
A tela continua importante, porque evita a viagem até o servidor e ajuda a pessoa. Para código, use <input type="text" inputmode="numeric">: o celular mostra o teclado numérico e o valor continua texto, com zeros. O atributo pattern do HTML já é ancorado no texto inteiro, então pattern="[0-9]{5}-?[0-9]{3}" basta. Evite type="number" para código: ele aceita e, sinal e ponto, e alguns navegadores mudam o valor com a roda do mouse. Para quantidade, type="number" com min, max e step="1" funciona bem.
Ponha o formato ao lado do rótulo ("CEP, ex.: 01310-100") e use a mesma frase de erro do servidor. A regra repetida nos dois lados é o assunto de plugin de validação no navegador não dispensa o servidor.
Erros comuns
- Regex sem âncoras.
[0-9]+aceita "12a", "abc1" e qualquer texto com um dígito no meio. - Converter antes de validar.
parseInttransforma "12a" em 12 eNumbertransforma vazio em 0, sem erro nenhum. - Guardar código como número. O CEP 01310-100 perde o zero e a etiqueta sai errada; CPF começando com zero vira outro documento.
- Uma regex genérica para tudo. "Só dígitos" aceita CPF com 3 dígitos e idade com 11. Cada campo tem tamanho e faixa.
- Apagar caracteres inválidos em silêncio. O integrador manda "12 unidades", o servidor grava 12 e o erro de integração nunca aparece.
- Confiar no
$em PHP ou Python. Uma quebra de linha no fim passa pela validação e chega ao banco.
Checklist
- Cada campo "numérico" está classificado como quantidade, código ou decimal.
- Toda regex de validação tem âncora no início e no fim (ou usa
fullmatch/\z). - A conversão acontece só depois da validação, com faixa conferida.
- CEP, CPF e afins são guardados como texto.
- A API devolve erro com o nome do campo e o formato esperado.
- Existe teste automatizado com "12a", "1e3", vazio, negativo e zero.
- O formulário usa
inputmode="numeric"para código e mostra um exemplo do formato.
Mande agora um "12a" direto para a API do seu formulário mais importante, sem passar pela tela: ela recusa com uma mensagem clara, ou grava 12 e segue em frente?
Referência: https://stackoverflow.com/questions/19715303/regex-that-accepts-only-numbers-0-9-and-no-characters/19715367