Someone landed on my site with the query "how do you write 123456789012345678901234 in words". The converter took the number, ran it through Number(), and answered as if it were 123456789012345680000000. No error, no warning — just a confident wrong answer with the last eight digits replaced by zeros.
That was the day I stopped storing "the number" as a number.
Three ways a float lies to a words converter
- Precision ends at 2⁵³. Anything past 9,007,199,254,740,992 gets rounded to the nearest representable double. A words converter has one job — every digit — and floats quietly drop digits.
-
String(1e21)is"1e+21". From a sextillion upward JavaScript stops giving you digits at all. Your parser sees a letter and either crashes or, worse, reads1. -
Cents.
Math.round(1.005 * 100)is100, not101, because1.005is really1.00499999…. On a check that's a one-cent lie, and checks are exactly where people use these converters.
The rewrite: digits are a string
The new engine never converts the integer part to a number. It keeps the digit string, splits it into groups of three from the right, and names each group with the short scale:
thousand, million, billion, trillion, quadrillion, quintillion,
sextillion, septillion, octillion, nonillion, decillion
That's twelve group names, so the hard limit is 36 digits — 999 decillion. Past that the converter says so instead of guessing. Each group of up to three digits is small enough to be a real number again, so the "one hundred twenty-three" part is ordinary code.
Decimals are read the way people say them aloud — digit by digit: 3.14 becomes "three point one four", 0.05 becomes "zero point zero five". No float ever touches the fraction either; it's just characters.
Rounding money without a float
Currency modes round to two decimals. Doing that on strings is a tiny bit of long addition:
function roundToCents(integer, fraction) {
const padded = `${fraction}000`.slice(0, 3); // first three fraction digits
let cents = Number(padded.slice(0, 2));
let whole = integer; // still a digit string
if (Number(padded.charAt(2)) >= 5) cents += 1;
if (cents === 100) { cents = 0; whole = incrementDigits(whole); }
return { integer: whole.replace(/^0+/, "") || "0", cents };
}
incrementDigits walks from the right, turning trailing 9s into 0s and bumping the first non-9 — the schoolbook carry. So 999.999 becomes "one thousand dollars" and 1.005 becomes "one dollar and one cent", which is what a human would write.
The English details that most converters skip
- Hyphens in twenty-one through ninety-nine. "Twenty one" is a typo in every style guide I checked.
- US vs UK "and". American: "one hundred one", "two thousand five". British: "one hundred and one", "two thousand and five" — and also "one million and two" when the last group has no hundreds. Both styles are options; the pounds mode uses the British one by default.
- Plurals and pence. "One cent", "two cents"; "one penny", "two pence"; "one euro", "two euros".
- The check line. US checks want the cents as a fraction: "One thousand two hundred thirty-four and 56/100". That's its own mode.
Reading what people actually paste
Nobody types 1234.56. They paste $1,234.56, or 1.234,56 from a European invoice, or 1 234 567 with spaces. The parser strips whitespace and currency symbols, then decides which separator is decimal:
- both present → the last one is the decimal point (
1,234.56and1.234,56both work); - only commas: one comma with exactly three digits after it is a thousands separator, otherwise it's a decimal (
12,5→ 12.5); - only dots: several dots are grouping (
1.234.567), one dot is a decimal.
Anything with a letter inside the digits — 1e6, 12abc — is refused with a message. Guessing wrong is worse than asking.
The widget, and how I keep two copies honest
The converter also ships as an embeddable widget for other sites: an iframe or a single <script> tag, no cookies, no analytics, no requests back to my server. That means the engine has to run on a stranger's page with no framework — so the widget carries a plain-JavaScript copy of the TypeScript engine.
Two copies of the same logic drift. The guard is a parity test: it evaluates the widget's JS source with new Function, then runs both implementations over every integer from 0 to 1,200 in both styles, plus about seventy nasty vectors — 36 nines, 1.005, 999.999, 1.234,56, -0, .5, 5., 1e6, empty string — in all six modes, and asserts identical output. One different character fails the build.
If you want to try it or embed it: the converter is at theunitools.com/en/math/number-to-words-converter and the widget page with the snippets is at theunitools.com/en/embed. The whole thing is free; the only thing I ask of people who embed it is to keep the small link under the box.
What I'd tell my past self
If the output is every digit of the input, the input is text, not a number. Floats are for arithmetic. Words are for people, and people write 36-digit numbers into converters just to see what happens — now they get the right answer.
Top comments (2)
British here... it's more common to say "a hundred and one".
Some comments may only be visible to logged-in visitors. Sign in to view all comments.