How to test currency formatting
Format the same amount with Intl.NumberFormat(locale, { style: 'currency', currency }) and assert on formatToParts, not on the string. In this run JPY kept zero fraction digits and rounded 1234.5 to 1,235, KWD kept three, and nl-NL placed the minus sign after the euro symbol.
Why check this
Run this when a currency or a market is added, on any build that touches a price component, and in regression after a dependency bump that moves the ICU version. The two decimal places everyone assumes are a property of the currency, not of money.
The failure it catches is a price that reads as wrong to the customer while every backend number is right. A Japanese customer sees 1,234.50 yen, a unit that does not exist. A Kuwaiti total silently loses its third decimal in a rounding step. A Dutch refund shows the minus sign in a place a regular expression anchored to the start of the string never finds, so the test that checks for a negative passes on a positive amount.
Prerequisites
- Node 22 with full ICU, or the console of any modern browser. This machine ran Node 22.23.2 with ICU 78.2 and CLDR 48, which knows 162 currency codes.
- The Intl.NumberFormat reference for
currencyDisplayandcurrencySign. - The list of currencies the product sells in, and the locales it renders them to. The two lists are independent: a euro price is rendered differently in Dublin and in Berlin.
- The separators are checked separately, in How to check the thousand separator and decimal separator. This page assumes that page has been run.
Steps
- Step 1.
Ask each currency how many fraction digits it has, instead of assuming two.
// c1.mjs console.log('currencies this build knows:', Intl.supportedValuesOf('currency').length); for (const c of ['USD', 'EUR', 'JPY', 'KRW', 'KWD', 'BHD', 'CLP', 'QQQ']) { const f = new Intl.NumberFormat('en-US', { style: 'currency', currency: c }); const o = f.resolvedOptions(); console.log(c, 'digits', o.minimumFractionDigits, o.maximumFractionDigits, ' 1234.5 ->', f.format(1234.5)); }currencies this build knows: 162 USD digits 2 2 1234.5 -> $1,234.50 EUR digits 2 2 1234.5 -> €1,234.50 JPY digits 0 0 1234.5 -> ¥1,235 KRW digits 0 0 1234.5 -> ₩1,235 KWD digits 3 3 1234.5 -> KWD 1,234.500 BHD digits 3 3 1234.5 -> BHD 1,234.500 CLP digits 0 0 1234.5 -> CLP 1,235 QQQ digits 2 2 1234.5 -> QQQ 1,234.50Three scales, not one. JPY, KRW and CLP round the half unit away. KWD and BHD keep a third decimal that a two-decimal column in the database cannot hold.
QQQis not a currency and was formatted anyway, with the default two digits. - Step 2.
Hold the currency fixed and change the locale, then read the order of the parts.
// c2.mjs for (const l of ['en-US', 'de-DE', 'fr-FR', 'nl-NL', 'it-IT', 'fi-FI']) { const f = new Intl.NumberFormat(l, { style: 'currency', currency: 'EUR' }); console.log(l.padEnd(6), JSON.stringify(f.format(1234.5)).padEnd(16), f.formatToParts(1234.5).map((p) => p.type).join(' ')); }en-US "€1,234.50" currency integer group integer decimal fraction de-DE "1.234,50 €" integer group integer decimal fraction literal currency fr-FR "1 234,50 €" integer group integer decimal fraction literal currency nl-NL "€ 1.234,50" currency literal integer group integer decimal fraction it-IT "1234,50 €" integer decimal fraction literal currency fi-FI "1 234,50 €" integer group integer decimal fraction literal currencyOne currency, three layouts. The symbol leads in en-US with nothing between, leads in nl-NL with a
literalspace, and trails in the other four. The it-IT row has nogrouppart at all. - Step 3.
Chase that missing group with a larger amount before filing it.
// c3.mjs for (const v of [1234.5, 12345.5]) { for (const l of ['it-IT', 'de-DE']) { console.log(String(v).padEnd(8), l, JSON.stringify(new Intl.NumberFormat(l, { style: 'currency', currency: 'EUR' }).format(v))); } }1234.5 it-IT "1234,50 €" 1234.5 de-DE "1.234,50 €" 12345.5 it-IT "12.345,50 €" 12345.5 de-DE "12.345,50 €"Italian groups from five digits up. The separator is not missing, it starts later, and German starts at four.
- Step 4.
Format a refund and compare the two negative forms.
// c4.mjs for (const l of ['en-US', 'de-DE', 'fr-FR', 'nl-NL']) { const opt = { style: 'currency', currency: 'EUR' }; console.log(l.padEnd(6), 'standard', JSON.stringify(new Intl.NumberFormat(l, opt).format(-1234.5)).padEnd(18), 'accounting', JSON.stringify(new Intl.NumberFormat(l, { ...opt, currencySign: 'accounting' }).format(-1234.5))); }en-US standard "-€1,234.50" accounting "(€1,234.50)" de-DE standard "-1.234,50 €" accounting "-1.234,50 €" fr-FR standard "-1 234,50 €" accounting "(1 234,50 €)" nl-NL standard "€ -1.234,50" accounting "(€ 1.234,50)"Under
accountingthree of the four locales drop the minus sign and use parentheses, while de-DE keeps the sign. In the standard form nl-NL puts the sign after the symbol, sotext.startsWith('-')is false on a negative amount. - Step 5.
Render three dollar currencies through each
currencyDisplayvalue.// c5.mjs for (const d of ['symbol', 'narrowSymbol', 'code', 'name']) { console.log(d.padEnd(13), ['CAD', 'AUD', 'USD'].map((c) => new Intl.NumberFormat('en-US', { style: 'currency', currency: c, currencyDisplay: d }).format(5)).join(' ')); }symbol CA$5.00 A$5.00 $5.00 narrowSymbol $5.00 $5.00 $5.00 code CAD 5.00 AUD 5.00 USD 5.00 name 5.00 Canadian dollars 5.00 Australian dollars 5.00 US dollarsnarrowSymbolrenders all three as$5.00. A checkout that picks it to look tidy stops telling the customer which dollar is being charged. - Step 6.
Read the symbol and the digits as code points, for the two cases a screenshot cannot settle.
// c6.mjs const cp = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' '); for (const l of ['en-US', 'de-DE', 'ja-JP']) { const sym = new Intl.NumberFormat(l, { style: 'currency', currency: 'JPY' }) .formatToParts(1234).find((p) => p.type === 'currency').value; console.log('JPY symbol in', l.padEnd(6), JSON.stringify(sym), cp(sym)); } const kw = new Intl.NumberFormat('ar-KW', { style: 'currency', currency: 'KWD' }); const s = kw.format(1234.5); console.log('KWD in ar-KW numberingSystem', kw.resolvedOptions().numberingSystem, 'length', s.length); console.log('KWD in ar-KW code points ', cp(s));JPY symbol in en-US "¥" U+00A5 JPY symbol in de-DE "¥" U+00A5 JPY symbol in ja-JP "¥" U+FFE5 KWD in ar-KW numberingSystem arab length 16 KWD in ar-KW code points U+200F U+0661 U+066C U+0662 U+0663 U+0664 U+066B U+0665 U+0660 U+0660 U+00A0 U+062F U+002E U+0643 U+002E U+200FThe Japanese locale uses U+FFE5, the fullwidth yen sign, where English uses U+00A5. The Kuwaiti string carries Arabic-Indic digits, its own separators at U+066C and U+066B, and an invisible U+200F right-to-left mark at each end.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| digits 0 0 for a currency | The currency has no minor unit | Remove the decimal column from that market's invoices and check the storage type. |
| digits 3 3 for a currency | Three minor digits, as for KWD and BHD | A two-decimal column truncates real money. Store minor units as integers. |
| The currency part comes last | The symbol trails the amount in that locale | Any string built by concatenation in your code is in the wrong order there. |
| A negative renders as € -1.234,50 | The sign sits after the symbol | Detect negatives from the value, never from the rendered text. |
| narrowSymbol output is $5.00 for CAD | The distinguishing prefix was dropped | Use symbol or code on any screen that charges money. |
| Digits outside 0 to 9 | The locale resolved a non-latn numbering system | Assert with formatToParts, or pin the system with the -u-nu-latn extension. |
Common mistakes
What to check next
- How to check the thousand separator and decimal separator: the grouping and decimal characters underneath every amount here.
- Zero decimal currency: how to store JPY and KWD amounts once the digit counts above are known.
- How to check currency rounding: where the rounding in step 1 happens and which side of the total it lands on.
- How to change locale in chrome: runs the same formats through a browser instead of Node.
- How to test rtl layout: what those right-to-left marks do to a price next to a label.
FAQ
How do I test currency formatting for every market at once?
Loop the locale and currency lists the product ships and print formatToParts for one amount and its negative. The output is small enough to read, and it makes the symbol position and the digit count visible without a browser.
Why does the same amount round differently per currency?
Intl.NumberFormat reads the minor unit count from CLDR. JPY resolved to 0 fraction digits here, so 1234.5 became 1,235. USD resolved to 2 and kept 1,234.50. The rounding follows the currency, not the locale.
Should the API send a formatted price?
Send an integer in minor units and the ISO code. The digit counts in step 1 show why a decimal string is ambiguous: 1234.500 is a valid KWD amount and an invalid USD one.
Which currencyDisplay should a checkout use?
symbol or code. Step 5 shows narrowSymbol rendering CAD, AUD and USD identically as $5.00, which removes the only thing telling the customer which currency is charged.
Does Intl reject a currency code it does not know?
No. This build knows 162 codes, and step 1 shows it formatting QQQ as QQQ 1,234.50 with the default two digits. Only a malformed code raises a RangeError: US returned Invalid currency code : US here.
Verified
Verified by Maks VernyNode 22.23.2ICU 78.2, CLDR 48
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny