DEV Community

Cover image for Stop Formatting Currency With Regex. `Intl.NumberFormat` Does It.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Stop Formatting Currency With Regex. `Intl.NumberFormat` Does It.

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:

function formatMoney(amount) {
  return '$' + amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
Enter fullscreen mode Exit fullscreen mode

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 fix everyone reaches for: patch the regex

The usual response to "it broke for amount X" is to patch the one case that broke:

function formatMoney(amount) {
  const sign = amount < 0 ? '-' : '';
  const fixed = Math.abs(amount).toFixed(2);
  return sign + '$' + fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
Enter fullscreen mode Exit fullscreen mode

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.

What's actually going on: formatting money needs locale data, not string surgery

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:

new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: 'EUR',
}).format(1234.5);
// "1.234,50 €"
Enter fullscreen mode Exit fullscreen mode

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:

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
// "$1,234.50"

new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(1234.5);
// "1 234,50 €"
Enter fullscreen mode Exit fullscreen mode

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:

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(-1234.5);
// "-$1,234.50"
Enter fullscreen mode Exit fullscreen mode

The options that cover the rest of the tickets

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:

new Intl.NumberFormat('en-US', { notation: 'compact' }).format(1234567);
// "1.2M"
Enter fullscreen mode Exit fullscreen mode

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:

new Intl.NumberFormat('en-US', { style: 'percent', signDisplay: 'exceptZero' }).format(0.052);
// "+5%"
new Intl.NumberFormat('en-US', { style: 'percent', signDisplay: 'exceptZero' }).format(0);
// "0%"
Enter fullscreen mode Exit fullscreen mode

Units, for "12 km" or "3.5 hr" without hand-building a suffix map:

new Intl.NumberFormat('en-US', { style: 'unit', unit: 'kilometer' }).format(12);
// "12 km"
Enter fullscreen mode Exit fullscreen mode

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.

The one real gotcha: construct it once, not per row

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:

// slow: builds a new formatter 10,000 times
rows.map(r => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(r.amount));
Enter fullscreen mode Exit fullscreen mode
// fast: one formatter, reused for every row
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
rows.map(r => money.format(r.amount));
Enter fullscreen mode Exit fullscreen mode

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.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Delete the helper

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()?

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

📚 Read next


🚀 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:

Top comments (0)