How to check the thousand separator and decimal separator

Format one number in every locale you ship, then read the separators as code points with formatToParts. In this run fr-FR grouped with U+202F, a narrow no-break space, ru-RU with U+00A0, and de-CH with an apostrophe. Two of the seven used the comma an English test expects, and one of those grouped in twos.

Why check this

Run this when a locale is added, and again in regression after any change to a formatting helper or to the ICU version a build pins. Separators are decided by CLDR data, not by your code, so a dependency bump moves them without a line of your source changing.

Two failures come out of it. A test compares a rendered amount against a string a developer typed by hand, the two look identical on screen, and the assertion fails with no visible difference to explain it. Or a parser reads a German figure back with parseFloat, gets a number one thousand times too small, and writes it to the database without an error anywhere.

Prerequisites

Steps

  1. Step 1.

    Format the same value in each locale and look at the seven results together.

    // fmt.mjs
    const n = 1234567.89;
    for (const l of ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'en-IN', 'ru-RU', 'sv-SE']) {
      console.log(l.padEnd(6), JSON.stringify(new Intl.NumberFormat(l).format(n)));
    }
    
    en-US  "1,234,567.89"
    de-DE  "1.234.567,89"
    fr-FR  "1 234 567,89"
    de-CH  "1'234'567.89"
    en-IN  "12,34,567.89"
    ru-RU  "1 234 567,89"
    sv-SE  "1 234 567,89"

    Four separators appear: comma, full stop, apostrophe and something that looks like a space. en-IN groups the top digits in twos.

  2. Step 2.

    Read the separators as code points, because the screen cannot tell two spaces apart.

    // parts.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', 'fr-FR', 'de-CH', 'ru-RU', 'sv-SE']) {
      const p = new Intl.NumberFormat(l).formatToParts(1234567.89);
      console.log(l.padEnd(6), 'group', cp(p.find((x) => x.type === 'group').value).padEnd(6),
                  'decimal', cp(p.find((x) => x.type === 'decimal').value));
    }
    
    en-US  group U+002C decimal U+002E
    de-DE  group U+002E decimal U+002C
    fr-FR  group U+202F decimal U+002C
    de-CH  group U+0027 decimal U+002E
    ru-RU  group U+00A0 decimal U+002C
    sv-SE  group U+00A0 decimal U+002C

    The two spaces are different characters. fr-FR uses U+202F, a narrow no-break space. ru-RU and sv-SE use U+00A0. Neither is the U+0020 on your keyboard.

  3. Step 3.

    Compare the rendered string with an expectation typed by hand.

    // compare.mjs
    const actual = new Intl.NumberFormat('fr-FR').format(1234567.89);
    const expected = '1 234 567,89';        // typed with a plain space
    console.log('equal   ', actual === expected);
    console.log('length  ', actual.length, expected.length);
    const i = [...actual].findIndex((c, k) => c !== expected[k]);
    console.log('mismatch at index', i, 'got U+' + actual.codePointAt(i).toString(16).toUpperCase(),
                'want U+' + expected.codePointAt(i).toString(16).toUpperCase().padStart(4, '0'));
    
    equal    false
    length   12 12
    mismatch at index 1 got U+202F want U+0020

    Same length, same glyph width on screen, different character. A diff in a test report shows two identical lines.

  4. Step 4.

    Feed each localized string back to the two parsers people reach for.

    // parse.mjs
    for (const l of ['de-DE', 'en-US']) {
      const s = new Intl.NumberFormat(l).format(1234.56);
      console.log(l, 'string', s, '| parseFloat', parseFloat(s), '| Number', Number(s));
    }
    
    de-DE string 1.234,56 | parseFloat 1.234 | Number NaN
    en-US string 1,234.56 | parseFloat 1 | Number NaN

    parseFloat stops at the first character it does not recognise and keeps what it has. The German amount comes back as 1.234, which is a valid number, a thousand times too small, and silent. Number at least refuses.

  5. Step 5.

    Parse the string back using the separators the locale itself reports.

    // roundtrip.mjs
    function parseLocaleNumber(text, locale) {
      const p = new Intl.NumberFormat(locale).formatToParts(12345.6);
      const group = p.find((x) => x.type === 'group').value;
      const decimal = p.find((x) => x.type === 'decimal').value;
      return Number(text.split(group).join('').replace(decimal, '.'));
    }
    for (const l of ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'en-IN', 'ru-RU']) {
      const s = new Intl.NumberFormat(l).format(1234567.89);
      const back = parseLocaleNumber(s, l);
      console.log(l.padEnd(6), s.padEnd(14), '->', back, back === 1234567.89 ? 'ok' : 'FAIL');
    }
    
    en-US  1,234,567.89   -> 1234567.89 ok
    de-DE  1.234.567,89   -> 1234567.89 ok
    fr-FR  1 234 567,89   -> 1234567.89 ok
    de-CH  1'234'567.89   -> 1234567.89 ok
    en-IN  12,34,567.89   -> 1234567.89 ok
    ru-RU  1 234 567,89   -> 1234567.89 ok

    No character literals anywhere. The same function handles the Indian grouping and the two kinds of space, because it never guesses what the separators are.

  6. Step 6.

    Check how many fraction digits the default keeps, before trusting a rendered amount.

    // maxfrac.mjs
    const v = 1234.56789;
    console.log('default              ', new Intl.NumberFormat('en-US').format(v));
    console.log('maximumFractionDigits', new Intl.NumberFormat('en-US').resolvedOptions().maximumFractionDigits);
    console.log('two decimals         ', new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v));
    
    default               1,234.568
    maximumFractionDigits 3
    two decimals          1,234.57

    The default keeps three fraction digits and rounds the rest away. A money amount rendered without explicit digit options is rounded by a rule nobody chose.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | group is U+00A0 or U+202F | The locale groups with a no-break space | Stop comparing against hand-typed strings. Assert on formatToParts output instead. | | decimal is U+002C | The decimal separator is a comma | Every parser on that path needs the locale. parseFloat will truncate. | | Grouping appears in twos, as in en-IN | The locale uses a 2-2-3 pattern | A test counting groups of three reports a defect that does not exist. | | A four-digit number has no group separator | The locale needs five digits before it groups | Verify with a five-digit value before filing it. | | maximumFractionDigits is 3 | No digit options were passed | Set minimumFractionDigits and maximumFractionDigits for any amount that is money. |

Common mistakes

Sign: An assertion on a formatted number fails and the two strings in the report look identical.Cause: fr-FR groups with U+202F and the expectation was typed with U+0020. Both strings are 12 characters long and render at almost the same width, so the report shows two lines that match. Print code points before believing a diff.
Sign: A German or French amount is stored one thousand times too small and nothing logged an error.Cause: parseFloat('1.234,56') returns 1.234. It reads until the comma, treats the full stop as a decimal point, and returns a number that passes every type check downstream. Number() returns NaN on the same input, which is the safer failure.
Sign: A locale renders 1234 without a group separator and a tester files it as a bug.Cause: Italian sets minimumGroupingDigits to 2, so grouping starts at five digits. it-IT prints 1234,50 and 12.345,50 for the same currency. Check a five-digit value before deciding the separator is missing.
Sign: A formatting test passes on a developer machine and fails on the CI runner, with no locale in the diff.Cause: Intl.NumberFormat() with no locale argument resolves to the operating system setting. On this machine resolvedOptions().locale returned uk-UA, not en-US. The test was asserting on whatever the machine was configured for. Pass the tag explicitly in every fixture.

What to check next

FAQ

How do I check number formatting without writing a script?

Open any browser console and run new Intl.NumberFormat('de-DE').format(1234567.89). For the separators themselves, call formatToParts instead of format and read the group and decimal entries.

Which locale is used when I pass none?

The runtime default. new Intl.NumberFormat().resolvedOptions().locale returned uk-UA on this machine, which is the operating system setting. A test that omits the locale tests the machine it runs on.

Can I turn grouping off for a test fixture?

Yes. new Intl.NumberFormat('de-DE', { useGrouping: false }).format(1234567.89) returned 1234567,89 here. The decimal separator still follows the locale, so the string is not a plain number.

Is the separator the same in Node and in the browser?

Both read CLDR data through ICU, so the values agree when the ICU versions do. Read process.versions.icu, which printed 78.2 here, and compare it against the browser build before treating any difference as a defect in your code.

Should the API send formatted numbers?

Send the unformatted value and format at the edge. An API that sends 1 234 567,89 forces every consumer to parse a locale it was not told about, which is the failure in step 4.

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.

basic6 minpublished updated Maks Verny