Parsing numbers typed with a comma decimal
A German customer enters 1.234,56 as an invoice amount. The application stores 1.234. Nobody notices until reconciliation, because the value is a plausible number — it is simply a thousand times too small.
Formatting a number for a locale is a solved problem with a standard API. Reading one back is not, and the built-in conversions fail in the two worst possible ways: one returns nothing, and the other returns something wrong.
Root cause: there is no Intl.NumberParser
Intl.NumberFormat renders 1234.56 as 1.234,56 for German and 1 234,56 for French. Nothing in the standard library performs the inverse.
The two built-in conversions both misbehave on localized input. Number('1.234,56') returns NaN, because the string is not a valid numeric literal — which at least fails visibly. parseFloat('1.234,56') returns 1.234, because it reads as far as it can and stops at the comma. That is the dangerous one: no error, a number of the right type, and the wrong magnitude.
Group separators vary more than most tables account for. A point in German, a comma in English, a narrow no-break space in French, an apostrophe in Swiss German, and in Indian English a grouping that is not even uniform — the first group of three, then groups of two. A hand-written per-locale table gets the common cases and fails on the rest.
Minimal reproducible example
Number('1.234,56'); // NaN
parseFloat('1.234,56'); // 1.234 ← wrong by a factor of 1000
parseFloat('1 234,56'); // 1 ← wrong by a factor of 1000
parseFloat('1’234.56'); // 1 ← Swiss apostrophe group separator
The fix: ask the formatter what its separators are
const partsCache = new Map<string, { group: string; decimal: string }>();
function separatorsFor(locale: string) {
let s = partsCache.get(locale);
if (!s) {
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
s = {
group: parts.find((p) => p.type === 'group')?.value ?? ',',
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
};
partsCache.set(locale, s);
}
return s;
}
export function parseLocaleNumber(input: string, locale: string): number | null {
const { group, decimal } = separatorsFor(locale);
const cleaned = input
.trim()
.split(group).join('') // split/join avoids escaping a regex metacharacter
.replace(decimal, '.')
.replace(/[^\d.\-]/g, ''); // currency symbols, stray spaces, percent signs
if (cleaned === '' || cleaned === '-' || Number.isNaN(Number(cleaned))) return null;
return Number(cleaned);
}
Two details carry the correctness. Deriving the separators from formatToParts means the function is right for every locale the runtime knows, including ones nobody tested — provided the runtime has the data, which is the concern in Intl polyfills and ICU data loading. And returning null rather than NaN forces the caller to handle the failure, which is what stops a bad parse becoming a stored amount.
Why type="number" does not solve it
The obvious reach is for a native numeric input, and it helps less than it appears.
A type="number" field validates against the browser’s locale rather than your application’s, so a reader whose interface is German but whose browser is configured for English gets a field that rejects the comma they are told to use. The value read back through the DOM is normalised, which sounds helpful and means the raw text the reader typed is no longer available to parse yourself.
It also brings behaviour that is unwanted in a money field: scroll-wheel increments that silently change an amount, spinner buttons, and inconsistent handling of leading zeros across engines.
The arrangement that works is a text field with inputmode="decimal", which asks a mobile keyboard for a numeric keypad without handing the browser control of the value. Parsing stays yours, the raw string stays available, and the field behaves the same in every engine.
<input type="text" inputmode="decimal" autocomplete="off"
aria-describedby="amount-hint" />
<span id="amount-hint">Use the format 1.234,56</span>
The hint is worth the line: readers routinely doubt which convention a field expects, and stating it removes the hesitation.
Which locale should the parser use?
The parser needs a locale, and there are three candidates in a typical request. Choosing the wrong one produces a field that rejects what the reader was shown.
The interface locale — the language the page is rendered in — is the right answer in nearly every case, because it is the locale the value was displayed with. A reader looking at 1.234,56 on the page will type in that convention, and a parser using any other locale disagrees with what is on screen.
The browser locale is what a native numeric input uses, and it is frequently different: someone with an English-configured browser reading a German interface. Using it means the field and the page disagree, which is exactly the inconsistency that makes native numeric inputs unsuitable here.
The billing or shipping country is unrelated to how a person types and should never drive parsing. It drives currency and address structure, which is a different question.
The rule that keeps this straight is parse in the locale you formatted in. If a value was rendered into the field by your own formatter, the same locale must read it back, and the round-trip assertion in the previous section is precisely a test of that property.
One consequence worth planning for: if a reader can switch language while a form is open, any number already in a field was formatted under the old locale. Re-formatting the field contents on switch — parse with the old locale, format with the new — keeps the field consistent with the page. Leaving it alone produces a value that the parser will now misread, which is a small edge case that produces a very confusing bug report.
Verification
test.each([
['de-DE', '1.234,56', 1234.56],
['en-US', '1,234.56', 1234.56],
['fr-FR', '1\u202f234,56', 1234.56], // narrow no-break space
['de-CH', '1’234.56', 1234.56],
['de-DE', '1234,5', 1234.5],
['de-DE', '', null],
['de-DE', 'abc', null],
])('%s parses %s', (locale, input, expected) => {
expect(parseLocaleNumber(input, locale)).toBe(expected);
});
test('a round trip is stable', () => {
const n = 1234.56;
const shown = new Intl.NumberFormat('de-DE').format(n);
expect(parseLocaleNumber(shown, 'de-DE')).toBe(n);
});
The round-trip assertion is the strongest single test here: whatever the formatter produces for a locale, the parser must read back. It fails immediately on any locale whose separator is not what a hardcoded table assumed.
When to escalate
If parsing works and stored values are still wrong, the value may be being rounded rather than misread — a different problem, covered in Intl.NumberFormat currency rounding mismatch.
If a specific locale fails, check that the runtime has data for it. A missing locale falls back to root conventions, so the derived separators are English ones and readers of that locale cannot enter anything.
If the field misbehaves while typing, the component is probably parsing on every keystroke and writing the parsed value back. Holding the raw string until blur fixes it, and it is the single most common cause of a cursor jumping to the end of a field.
FAQ
Should the field accept both conventions?
Accepting an unambiguous alternative is friendly — 1234.56 typed into a German field is not ambiguous, since a point followed by exactly two digits at the end is a decimal in either reading. Genuine ambiguity, such as 1.234, must not be guessed: it is either a thousand or one and a bit, and the only safe response is to accept the locale convention and state it.
What about Indian grouping?
The lakh and crore grouping puts the first separator after three digits and then every two. Because the parser removes group separators wherever they appear rather than assuming a spacing, it handles that without a special case — which is another argument for stripping rather than pattern-matching.
Does this apply to percentages and units?
Yes, with one addition: strip the symbol before parsing and remember what it was, because a value entered as a percentage is usually stored as a fraction. Doing that conversion in the parser rather than at the call site is what keeps it consistent.
How should the parsed value be stored?
For money, as integer minor units rather than a floating-point major-unit value. For measurements, in a canonical unit with the unit recorded separately. In both cases the stored form should not depend on how it was typed.
Should the server parse localized input too?
No — the server should receive an already-parsed machine number. Sending localized text to an API means the server needs the reader locale to interpret it, and any consumer that forgets produces a silent misreading. Parse at the boundary where the locale is unambiguous, which is the client, and send a canonical value.
What about numbers with non-Latin digits?
Several locales can render digits in their own numbering system, and readers using those keyboards type them. Converting them is a normalisation step before parsing: the digit characters have known numeric values, so mapping them to Latin digits first lets the rest of the parser work unchanged. It is worth adding when you serve locales where those keyboards are common, and it is dead code everywhere else.
Related
- Internationalizing Forms & Input — the parse gap in the wider context of form input.
- Date & Number Formatting Standards — the formatting direction this inverts.
- Intl.NumberFormat currency rounding mismatch — what to do with the number once parsed.
- Intl Polyfills & ICU Data Loading — why a missing locale makes the derived separators wrong.
Part of Internationalizing Forms & Input.