I run a small site with a few hundred calculators. One of them writes an amount out in words — the thing you have to do on a cheque or a contract in Spanish. It is by a wide margin my most successful page: it brings 43 of the 44 search clicks the whole site gets in a month.
Yesterday I finally looked at which searches those were. Almost all of them were shaped like this:
como se escribe con letra 1,370,801,919.12
3.635.758 escribir letras
10,377.84 se escribe en letra
como se escribe 77107,10 enletra
People do not arrive wondering how the tool works. They arrive with a number already in their hand, copied out of an invoice, and they paste it.
So I pasted one myself.
3.635.758 went into the field. The field showed 758. And the page answered, in complete confidence:
setecientos cincuenta y ocho euros
Seven hundred fifty-eight euros. The number was three million six hundred thirty-five thousand seven hundred fifty-eight. Off by a factor of about 4,800, with no error, no warning, nothing red. On a page whose entire job is to produce the amount you then sign your name under.
Why it happens
The field was <input type="number">.
That input does not accept "a number as people write it". Per the HTML spec it accepts a valid floating-point number: optional sign, digits, optional . and more digits, optional exponent. That's it. No thousands separators. No locale. 1,234.56 is not a valid value. Neither is 1.234.567,89. Neither is 1 234,56.
What the browser does with an invalid value is the actual problem. It doesn't reject the input in a way you notice. Typing character by character, each keystroke is sanitized against that grammar, and you are left with whatever survived — in my case, 758. Pasting usually leaves you with an empty string instead, and element.value is "". valueAsNumber hands you NaN.
Every one of those outcomes is silent. The field looks fine. And that is the part worth internalising: type="number" doesn't protect you from bad input, it converts bad input into plausible input. A blank field is a bug you notice. 758 is a bug that ships.
The same bug from the other end
While I was in there I found the server-side half:
const normalized = value.replace(",", ".").trim();
const numeric = Number(normalized);
return Number.isFinite(numeric) ? numeric : fallback;
String.prototype.replace with a string pattern replaces the first match only. I wrote that line myself and had read past it many times.
-
"1234,56"becomes"1234.56"becomes 1234.56. Fine — which is why it survived so long. -
"1 234,56"becomes"1 234.56"becomesNaN, and falls back to 0. -
"1,234.56"becomes"1.234.56"becomesNaN, and falls back to 0.
So the two halves of the same system failed in opposite directions — one invented a small number, the other invented zero — and both did it quietly.
Reading a number the way a person reads it
You cannot fix this by picking one locale. My own traffic proves it: 10,377.84 and 77107,10 are both real inputs from real people on the same page, one written the American way and one the Spanish way. Force either convention and you are wrong for half your users.
But you don't need to guess, because a human reading 1.370.801.919,12 has no trouble at all. Write down what they're doing:
-
Both
.and,appear — the one that appears last is the decimal separator, the other groups thousands.1,370,801,919.12decides on.;208.333.333,33decides on,. -
One kind of separator, and it repeats — it groups thousands.
3.635.758has two dots, so the dots are grouping. -
One separator, exactly three digits after it — genuinely ambiguous.
1.234is "one thousand two hundred thirty-four" to a Spaniard and "one point two three four" to an American, and nothing in the string tells you which. This is the only case where the page's own locale decides. -
Anything else — decimal separator.
77107,10,1.5,0,5.
Here it is, minus the currency-symbol stripping:
export function parseHumanNumber(raw, decimalSeparator) {
const cleaned = raw.replace(/[\s ]/g, "").replace(/[€$£¥]/g, "");
if (!cleaned) return null;
const negative = cleaned.startsWith("-");
const body = cleaned.replace(/^[+-]/, "");
if (!/^[\d.,]+$/.test(body)) return null;
const lastDot = body.lastIndexOf(".");
const lastComma = body.lastIndexOf(",");
let decimalAt = -1;
if (lastDot >= 0 && lastComma >= 0) {
decimalAt = Math.max(lastDot, lastComma); // rule 1
} else if (lastDot >= 0 || lastComma >= 0) {
const at = Math.max(lastDot, lastComma);
const mark = body[at];
const howMany = body.split(mark).length - 1;
const digitsAfter = body.length - at - 1;
if (howMany > 1) decimalAt = -1; // rule 2
else if (digitsAfter === 3) // rule 3
decimalAt = mark === decimalSeparator ? at : -1;
else decimalAt = at; // rule 4
}
const wholeRaw = decimalAt >= 0 ? body.slice(0, decimalAt) : body;
const fraction = decimalAt >= 0 ? body.slice(decimalAt + 1) : "";
if (/[.,]/.test(fraction)) return null;
// Separators left in the integer part have to be real grouping:
// one kind of mark, groups of exactly three.
if (/[.,]/.test(wholeRaw)) {
if (wholeRaw.includes(".") && wholeRaw.includes(",")) return null;
const mark = wholeRaw.includes(".") ? "." : ",";
const groups = wholeRaw.split(mark);
if (groups[0].length < 1 || groups[0].length > 3) return null;
if (groups.slice(1).some((g) => g.length !== 3)) return null;
}
const whole = wholeRaw.replace(/[.,]/g, "");
if (!whole && !fraction) return null;
const value = Number(`${whole || "0"}.${fraction || "0"}`);
return Number.isFinite(value) ? (negative ? -value : value) : null;
}
That last block is the one I nearly skipped, and it matters most. Without it, 1,23,4.5 parses to 1234.5 — a confident answer to a string that means nothing. Returning null on garbage is the whole point. If you are replacing a silent wrong number, do not replace it with a different silent wrong number.
The element itself
<input
type="text"
inputMode="decimal"
value={typed}
onChange={(e) => {
setTyped(e.target.value);
setValue(parseHumanNumber(e.target.value, ",") ?? NaN);
}}
/>
inputMode="decimal" still brings up the numeric keypad on phones, which is the one thing type="number" was genuinely providing here. You also lose the spinner arrows, and nobody has ever wanted to increment an invoice total by one.
There is a React trap in those few lines. The typed text and the parsed number have to be separate state. If the input's value is the parsed number, then the moment someone types 1, the value is 1, React re-renders, and the comma disappears from under their fingers. They cannot type a decimal at all. Show what they typed; compute from what you understood.
One more change, and it costs nothing
The page now prints the amount it understood, in digits, right beside the words:
1.370.801.919,12 € -> mil trescientos setenta millones ochocientos
un mil novecientos diecinueve euros con doce céntimos
Any parser like this has a genuinely ambiguous case — rule 3 above. I can pick a sensible default, but I cannot be right every time, and a tool that writes the sum on a contract has no business being quietly wrong. Echoing the parsed number back turns an invisible failure into one the reader catches in half a second.
The tests are just the search log
I didn't invent test cases. I pasted in the actual queries people had used to reach the page:
assert.equal(parse("1,370,801,919.12", ","), 1370801919.12);
assert.equal(parse("3.635.758", ","), 3635758);
assert.equal(parse("10,377.84", ","), 10377.84);
assert.equal(parse("77107,10", ","), 77107.1);
assert.equal(parse("208.333.333,33", ","), 208333333.33);
assert.equal(parse("$14.688.000", ","), 14688000);
assert.equal(parse("1 234,56", ","), 1234.56);
// ambiguity resolved by the page's language
assert.equal(parse("1.234", ","), 1234); // Spanish page
assert.equal(parse("1.234", "."), 1.234); // English page
// and the ones that must not produce an answer
for (const junk of ["", "abc", "12abc", "1.2.3,4,5", "1,23,4.5"])
assert.equal(parse(junk, ","), null);
Your search console already knows the exact strings your users bring you. It is the best fixture list you will ever get, and it is free.
The takeaway
type="number" is fine for a quantity picker. It is the wrong control for any figure a person copies from somewhere else — an invoice total, a salary, a price, an account balance — because those arrive already formatted, and formatting is precisely what that element cannot survive.
Go and paste 1,234.56 into your own form right now. Then paste 1.234,56. Then look at what your backend stored.
The tool in question, if you want to see the fix in place: Número a letras — it writes amounts out in Spanish for contracts and cheques, in euros, pesos or dollars.
Top comments (0)