Lesson 16·Unit 5 · Strings, names, and input·Locale-aware input handling·Lv 201·~7 min
Parsing what users type: numbers, dates & digits
Intl.NumberFormat turns 1234.56 into 1.234,56 for a German user, and nothing in ECMA-402 turns it back. Number('1.234,56') is NaN, parseFloat reads it as 1.234 and stops, and '01/02/03' names three different dates in three markets. The inverse of formatting is code you own.
By the end
- ·Explain why Number('1.234,56') gives you NaN, and why parseFloat quietly handing back 1.234 is worse.
- ·Pull a locale's group and decimal separators out of formatToParts at runtime instead of guessing them.
- ·Fold Arabic-Indic, Extended Arabic-Indic, and full-width digits down to ASCII with code-point arithmetic.
- ·Keep the wire format (ISO 8601, plain numbers) locale-free, and do the locale work at the display edge.
The problem
A checkout has a free-text amount field. A customer in Munich types 1.234,56; the backend runs parseFloat and books 1.234: three orders of magnitude short, with no error raised anywhere. A customer in Cairo types ١٬٢٣٤٫٥٦ and gets NaN. Formatting bugs at least show up on screen; parsing bugs corrupt the data you store, and the standard library offers no locale-aware parser to catch them: every Intl API formats, none of them read.
How it works
Formatting has an inverse, and nobody ships it
Every Intl API formats. Intl.NumberFormat has format, formatToParts, formatRange, and nothing that reads a string back. ECMA-402 ships no number-parsing API at all, so the moment a user types a localized value into a text field, you are past the edge of the standard library.
JavaScript's own readers accept one grammar. Number() accepts the spec's numeric grammar (ASCII digits, one . as the decimal point, no grouping characters) and it is all-or-nothing: Number('1.234,56') is NaN. parseFloat() is the dangerous one. It reads the longest valid prefix and silently drops the rest. The German 1.234,56 becomes 1.234: a plausible number, three orders of magnitude off, and no error anywhere to catch.
The committee left parsing out deliberately: formatting is a function, and parsing is a guess. 1.234 means one thousand two hundred thirty-four to a German reader and a little over one to an American one. No API can decide between them unless you name the locale, so the committee shipped the half that has a right answer. The other half is yours, and Intl still does the hard part. formatToParts on a known number labels every character of the formatted output. That is how you derive a locale's group and decimal separators at runtime instead of hardcoding a table that CLDR will eventually move out from under you.
const parts = new Intl.NumberFormat("de-DE").formatToParts(1234567.89);
// [{type:"integer",value:"1"},{type:"group",value:"."}, …]
const group = parts.find(p => p.type === "group")?.value; // "."
const decimal = parts.find(p => p.type === "decimal")?.value; // ","How it works
Display format vs wire format
Keep two representations and never confuse them. The wire format is canonical and locale-free: ISO 8601 for dates (2003-02-01), plain JSON numbers or integer minor units for money. The display format is whatever the user's locale renders. It exists only at the edges: a formatter produces it on the way out, and your parser removes it immediately on the way in.
Dates show why the wire format matters most. 01/02/03 is January 2, 2003 to a month-first reader, February 1, 2003 to a day-first reader, and February 3, 2001 under year-first conventions: every field is a valid day, month, and year, so nothing fails. A number parsed with the wrong locale tends to produce an implausible value you might notice. A date parsed with the wrong locale produces a plausible wrong day.
The digits themselves are locale data too, and CLDR calls them numbering systems. ar-EG resolves to arab (٠١٢٣٤٥٦٧٨٩) and fa-IR to arabext, while a Japanese IME hands you full-width forms even though ja-JP defaults to latn. Folding them is arithmetic, not a lookup table. Every decimal digit block stores its ten digits in order starting at zero, so subtracting the block's first code point gives you the value. Subtract U+0660, U+06F0, or U+FF10 from any digit in those blocks and you have ASCII.
See it yourself
Try these, in order
Each step triggers a specific failure you should recognize on sight.
- 1Leave "Paste as a user from" on "de-DE · Germany" and read the results table. Number(input) is NaN, but parseFloat(input) returns 1.234, flagged "kept only the leading digits": the amount silently shrank by three orders of magnitude. The parser that fails loudly is the safe one here.
- 2Switch to "fr-FR · France" and check the Group separator card under "A parser that asks the locale first". The separator is U+202F, a narrow no-break space: it looks like a space but matches neither a typed space nor a hardcoded ",". The demo derived it from formatToParts at runtime instead of a lookup table.
- 3Switch to "ar-EG · Egypt". All three standard parsers fail: Arabic-Indic digits (U+0660–U+0669) are outside JavaScript's number grammar. In "The parse, step by step", step 1 folds each digit by subtracting the block start from its code point, and step 4 reads 1234.56.
- 4Switch to "ja-JP · full-width digits". Even the digits an IME produces (U+FF10–U+FF19) defeat parseFloat. The Numbering system card reads fullwide because the demo's tag carries u-nu-fullwide. Plain ja-JP defaults to Latin digits. The full-width forms come from the keyboard rather than the locale.
- 5Back on "de-DE · Germany", clear "Amount, as typed" and type 1,2,3. Press "Refill the example" when done. Step 3 turns both commas into decimal points, so step 4 returns NaN: the hand-built parser rejects malformed input instead of guessing. Deriving separators does not make bad input good.
- 6Scroll to "The same string, three dates" and compare the "Reads 01/02/03 as" column. January 2, 2003. February 1, 2003. February 3, 2001. Three readers, three days, and no error anywhere, because every field is a valid day, month, and year. This is why dates cross the wire as ISO 8601.
What the standard parsers do with it
Pick a market and the field fills with 1234.56 (123456.78 for India) as a user there would type it. Then edit it yourself.
Dot groups thousands, comma marks the decimal: the exact mirror of en-US.
| Parser | Result | What happened |
|---|---|---|
| Number(input) | NaN | Accepts the whole string or nothing. Only U+002E is a decimal point. Grouping characters are not in the grammar. |
| parseFloat(input) | 1.234 ← kept only the leading digits | Reads until the first character it cannot use, then stops: no error, no NaN. The dangerous one. |
| <input type="number"> | — | What this browser keeps when code assigns the string to a number input. Engines differ, so the demo measures this row live instead of claiming an output. |
A parser that asks the locale first
Intl will not parse for you, but it will tell you the rules. Format a known number with formatToParts() and read the separators out of the parts. This is that derivation, live, for de-DE.
Deriving separators…
The same string, three dates
Numbers at least fail loudly. Short dates fail silently: every field of 01/02/03 is a valid day, month, and year. Each row below formats the known date 2003-02-01 with 2-digit fields, reads the field order out of formatToParts(), and then applies that order to 01/02/03.
01/02/03
| Locale | Field order | Formats 2003-02-01 as | Reads 01/02/03 as |
|---|---|---|---|
| Deriving field orders… | |||
Three readers, three different days, and no way to tell from the string which one the writer meant. The rule: never parse a user-typed short date without knowing the locale. In the UI, a date picker or three labeled fields removes the ambiguity. Between systems, send ISO 8601 (2003-02-01), which reads the same everywhere.
If you remember one thing
Intl formats and never parses. The inverse is yours. Derive each locale's separators from formatToParts, fold digit blocks by code-point arithmetic, and keep the wire format (ISO 8601, plain numbers) locale-free.
What to do about it
- Derive, don't hardcode.
Intl.NumberFormat(locale).formatToParts()on a known number tells you the locale's group and decimal separators. Parse with those, never withreplace(",", ""). - Fold non-ASCII digits before validating: Arabic-Indic (U+0660–U+0669), Extended Arabic-Indic (U+06F0–U+06F9), and full-width (U+FF10–U+FF19) digits all map to ASCII by subtracting the block start from the code point.
- Keep locale strings at the edge. On the wire, send ISO 8601 dates and plain JSON numbers; render locale formats only at display time, parse them only at input time.
- Never parse a user-typed short date.
01/02/03is ambiguous by construction: use a date picker, separate labeled fields, or require the ISO order. - For identifiers (card numbers, postcodes), prefer
<input type="text" inputmode="numeric">overtype="number"; number inputs silently discard or round what users type.
Use this with
Stakeholders
Moments
- ·Reviewing any free-text amount, quantity, or date field
- ·Designing form validation for a multi-market launch
- ·API contract review: deciding what format crosses the wire
Field note
In February 2020 the GOV.UK Design System dropped <input type="number">. Research showed browsers silently discarding letters users typed, rounding numbers of 16+ digits, and converting large values to exponential notation. NVDA also announced the field as an unlabeled spin button. Their replacement for numeric identifiers is <input type="text" inputmode="numeric">: the mobile number keypad without the destructive reinterpretation. The general lesson: any control that reinterprets what the user typed is a parser, and it needs the same scrutiny as one you wrote yourself.
Quick check
3 questions · pass at 2+
Question 1/3
A German user types 1.234,56 into a free-text amount field. What does Number('1.234,56') return?
Question 2/3
Same string, but the code calls parseFloat('1.234,56'). What happens?
Question 3/3
A user in Cairo types the digit ٥ (U+0665). How does a locale-aware parser map it to ASCII 5?