All lessons

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.

formatToPartsnumbering systemsparsing

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; // ","
The derivation the demo runs live: format a known number, read the separators out of the labeled parts.

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.

  1. 1
    Leave "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.
  2. 2
    Switch 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.
  3. 3
    Switch 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.
  4. 4
    Switch 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.
  5. 5
    Back 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.
  6. 6
    Scroll 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.

ParserResult
Number(input)NaN
parseFloat(input)1.234 ← kept only the leading digits
<input type="number">

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

LocaleField orderReads 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 with replace(",", "").
  • 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/03 is 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"> over type="number"; number inputs silently discard or round what users type.

Use this with

Stakeholders

EngineeringQAProduct

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.

GOV.UK: why we changed the input type for numbers

Quick check

3 questions · pass at 2+

  1. Question 1/3

    A German user types 1.234,56 into a free-text amount field. What does Number('1.234,56') return?

  2. Question 2/3

    Same string, but the code calls parseFloat('1.234,56'). What happens?

  3. Question 3/3

    A user in Cairo types the digit ٥ (U+0665). How does a locale-aware parser map it to ASCII 5?

Words you'll hear

Where to read more

Related lessons