Stop Formatting Currency With Regex. Intl.NumberFormat Does It.
A hand-rolled formatMoney() with toFixed() and a comma regex looks fine in a US demo and quietly breaks the moment a real user, a negative balance, or a non-dollar locale shows up. Intl.NumberFormat has handled all of it, built into every modern browser, since before most teams' codebases existed.

Open any finance dashboard's test suite and there's a decent chance you'll find a function like this, usually written once, two years ago, by someone who is no longer on the team:
It passes its tests. formatMoney(1234.5) returns "$1,234.50", which is exactly what the ticket asked for. Nobody has touched it since, because it works — until a refund comes through as a negative number, or a stakeholder asks for a per-unit cost that needs four decimal places, or the company opens an EU region and legal asks why every price shows up with a comma where European customers expect a period.
None of those are edge cases. They're Tuesday.
The usual response to "it broke for amount X" is to patch the one case that broke:
That fixes negatives. It does not fix the next report, four decimal places: formatMoney(1234.5678) still runs the same comma-grouping regex across the entire string, digits after the decimal point included — so instead of "$1,234.5678" it can silently insert a comma inside the fractional part too, because the regex has no idea where the decimal point is relative to its own grouping. And none of this touches the actual question underneath the ticket: a German user doesn't want "1,234.50" at all. They want "1.234,50" — comma and period swapped — followed by a € with a non-breaking space in front of it, not a $ in front with no space. A regex built around US formatting conventions can't produce that output by patching cases one at a time, because it never encoded "how do numbers look in Germany" in the first place. There's no case to patch for that; the whole function is missing the concept.
Grouping separators, decimal marks, digit counts, and where the currency symbol sits relative to the number are not a style choice JavaScript can derive from an amount — they're a property of the locale, shipped as data in every browser:
| Locale | 1234.5 formatted as EUR |
|---|---|
en-US | €1,234.50 |
de-DE | 1.234,50 € |
fr-FR | 1 234,50 € |
Same number, same currency, three genuinely different strings — none of them wrong, none of them producible by one regex. That's the part a hand-rolled formatter can't cover without reimplementing a chunk of CLDR, the Unicode Consortium's locale database, which is exactly what Intl.NumberFormat already did, in every JS engine, years ago:
One constructor call, the correct separators, the correct symbol placement, for a locale the author of the regex has never had to think about. Swap the first argument and everything downstream follows:
And the negative-number bug the regex needed a patch for is just... handled, because the formatter understands "negative" as a concept, not a leading character to tape on:
Currency is the headline feature, but the same formatter answers most of the other tickets that end up patched into a regex over time:
Compact numbers, for a "1.2K followers" style display, without writing a >= 1000 ? ... : ... ladder:
Signed deltas, for a dashboard showing "+5.2%" on a good day and "-5.2%" on a bad one — signDisplay: 'exceptZero' adds the sign everywhere except an exact zero, which is usually the one case a hand-rolled version gets wrong first:
Units, for "12 km" or "3.5 hr" without hand-building a suffix map:
All of it reads the user's own locale by default — new Intl.NumberFormat() with no locale argument uses the runtime's configured locale, which in a browser generally matches what navigator.language reports — so a page doesn't even need to hardcode which country it's formatting for.
Intl.NumberFormat resolves a locale's rules when it's constructed, and that resolution is generally understood to be the heavier step compared to calling .format() on an already-built instance — which is why the common recommendation is to build the formatter once and reuse it, rather than constructing one per row inside a render loop:
Same output, one constructor call instead of ten thousand. This is the detail that actually shows up in a profiler on a long table — not whether Intl.NumberFormat is "slower than a regex" in the abstract, but whether you're paying its setup cost once or on every single row.
Runs right in your browser — poke at it and watch the concept react live.
The regex-based formatMoney() isn't a bad instinct — it's solving a problem that feels like string manipulation because the output is a string. But the actual problem is locale data, and that's not something worth re-deriving one bug report at a time when the platform has shipped a correct, fast, built-in answer since before most teams' codebases existed.
Next price, percentage, or "12.3K views" you're tempted to hand-format: what locale is it actually for, and have you checked what Intl.NumberFormat does with it before reaching for .toFixed()?
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- addDays() Mutated a Date Three Components Away From Where I Called It
- You're truncating bios with
.slice().Intl.Segmenterknows where the emoji actually end. - You're installing date-fns for 'X hours ago'.
Intl.RelativeTimeFormatdoes it natively.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.