A German user types 1.234,56 into an amount field and the form either rejects it or — worse — stores 1.234 because parseFloat stopped at the comma; a French user types 1 234,56 with a narrow no-break space and gets “Enter a number”. Intl.NumberFormat can format in every locale, but there is no built-in Intl number parser.

This page, part of input masking and formatting, builds a parser from the locale’s own formatting conventions, formats on blur with Intl, and handles currency precisely — in integer minor units, with the right number of decimal places per currency.


Context and prerequisites

What varies by locale:

  • Decimal separator — . (en-US, en-GB), , (de-DE, fr-FR, es-ES), ٫ (some Arabic locales).
  • Grouping separator — , (en-US), . (de-DE), narrow no-break space U+202F (fr-FR), apostrophe ’ (de-CH), or none.
  • Grouping pattern — thousands in most locales; lakh/crore grouping in en-IN (12,34,567).
  • Digits — Latin digits in most locales, but Arabic-Indic (٠١٢) and others in some.
  • Currency — symbol position, spacing, and the number of minor units: USD and EUR have 2, JPY has 0, KWD and BHD have 3.

Intl.NumberFormat.prototype.formatToParts reveals the decimal and group characters for any locale, which is enough to build a reliable parser. For currency, resolvedOptions().maximumFractionDigits on a currency formatter gives the minor-unit count.

The same amount in five locales In en-US the amount is written with commas for grouping and a dot for decimals. In de-DE dots group and a comma marks decimals. In fr-FR a narrow no-break space groups and a comma marks decimals. In de-CH an apostrophe groups and a dot marks decimals. In en-IN grouping follows the lakh pattern, with commas after the first three digits and then every two. Locale Display Decimal Grouping en-US 1,234,567.89 . , every 3 de-DE 1.234.567,89 , . every 3 fr-FR 1 234 567,89 , narrow no-break space de-CH 1’234’567.89 . ’ every 3 en-IN 12,34,567.89 . , lakh pattern

The core pattern: a parser derived from Intl, and minor-unit currency

export interface LocaleNumber {
  parse(text: string): number | null;
  format(n: number): string;
}

export function localeNumber(locale: string, options: Intl.NumberFormatOptions = {}): LocaleNumber {
  const fmt = new Intl.NumberFormat(locale, options);
  const parts = fmt.formatToParts(1234567.891);
  const group = parts.find((p) => p.type === "group")?.value ?? "";
  const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";

  // Map the locale's digits (e.g. Arabic-Indic) to 0–9.
  const digits = [...new Intl.NumberFormat(locale, { useGrouping: false }).format(9876543210)].reverse();
  const digitMap = new Map(digits.map((d, i) => [d, String(i)]));

  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  // Accept the locale's group char AND ordinary/no-break spaces users actually type.
  const groupRe = new RegExp(`[${esc(group)}\\s\\u00A0\\u202F]`, "g");

  return {
    parse(text) {
      let t = text.trim();
      if (t === "") return null;
      t = [...t].map((c) => digitMap.get(c) ?? c).join("");
      t = t.replace(/[^\d\-+.,'’\s  ٫]/g, "");   // drop currency symbols and letters
      t = t.replace(groupRe, "");
      if (decimal !== ".") t = t.split(decimal).join(".");
      // Anything left that is not a plain number means the input was ambiguous or wrong.
      if (!/^[-+]?\d*(?:\.\d*)?$/.test(t) || t === "" || t === "." || t === "-") return null;
      const n = Number(t);
      return Number.isFinite(n) ? n : null;
    },
    format: (n) => fmt.format(n),
  };
}

// Currency: store INTEGER minor units; derive precision from the currency itself.
export function currencyField(locale: string, currency: string) {
  const fmt = new Intl.NumberFormat(locale, { style: "currency", currency });
  const minor = fmt.resolvedOptions().maximumFractionDigits ?? 2;   // JPY 0, USD 2, KWD 3
  const num = localeNumber(locale, { minimumFractionDigits: minor, maximumFractionDigits: minor });
  return {
    minorUnits: minor,
    /** Text → integer minor units, or an error message. */
    parse(text: string): { ok: true; minor: number } | { ok: false; message: string } {
      const n = num.parse(text);
      if (n === null) return { ok: false, message: `Enter an amount, like ${fmt.format(1234.5)}.` };
      const places = (String(text).split(/[.,٫]/).pop() ?? "").replace(/\D/g, "");
      // Reject extra precision rather than silently rounding the user's money.
      const scaled = n * 10 ** minor;
      if (Math.abs(scaled - Math.round(scaled)) > 1e-6) {
        return { ok: false, message: minor === 0 ? "Enter a whole amount." : `Use at most ${minor} decimal places.` };
      }
      return { ok: true, minor: Math.round(scaled) };
    },
    format: (minorAmount: number) => fmt.format(minorAmount / 10 ** minor),
  };
}

Step-by-step walkthrough

  1. Know the user’s locale. Use the app’s configured locale, not only navigator.language; a German user of an English-language site may still type German numbers. Some forms let the user’s account locale decide.
  2. Derive separators from formatToParts. Formatting a sample number in the locale tells you exactly which characters mean “group” and “decimal”.
  3. Parse leniently, then check strictly. Strip grouping characters and spaces, convert the locale decimal to ., map native digits, and reject anything that is not a clean number afterwards.
  4. Store money as integer minor units. 1234.56 euros becomes 123456 cents. Sums and comparisons are then exact, avoiding the floating-point drift discussed in controlled number inputs and intermediate values.
  5. Take precision from the currency. maximumFractionDigits on a currency formatter gives 0 for JPY and 3 for KWD; validate the user’s decimal places against it.
  6. Format on blur with Intl. While focused, leave the user’s text alone; on blur, if it parsed, replace it with the locale’s formatted display.

Why ambiguity must be an error, not a guess

1,234 means one thousand two hundred thirty-four in en-US and one point two three four in de-DE. If the parser tries to guess — “a comma followed by exactly three digits is probably grouping” — it will be wrong for someone, and the error is invisible: the form accepts the input and stores a value a thousand times off. Parsing strictly by the known locale, and rejecting input that does not fit it with a message showing an example in the expected format, turns a silent data error into a visible, fixable one. Showing the formatted result on blur (“€1.234,00”) gives users a final chance to spot a misreading before they submit.

Parsing "1.234,56" for de-DE formatToParts for de-DE reports dot as the group separator and comma as the decimal separator. The input text 1.234,56 has native digits already. Removing group characters gives 1234,56. Replacing the locale decimal with a dot gives 1234.56, which passes the strict number check. Multiplying by ten to the power of two for EUR gives 123456 minor units, an exact integer. On blur, the value is formatted back as 1.234,56 euro. Separators from Intl group ".", decimal "," formatToParts(1234567.891) for de-DE. Strip grouping "1.234,56" → "1234,56" Also strips spaces and no-break spaces. Normalise decimal "1234,56" → "1234.56" Strict check: a clean number remains. Minor units 1234.56 × 10² → 123456 Exact integer; formatted on blur as "1.234,56 €".

Failure modes and edge cases

1. Using parseFloat

parseFloat("1.234,56") is 1.234, silently. Never parse user-entered numbers with parseFloat or Number directly; always normalise by locale first.

2. Space characters

French formatting uses a narrow no-break space (U+202F), and users type ordinary spaces or no-break spaces (U+00A0) — or paste them from spreadsheets. Accept all three as grouping.

3. Negative numbers

Some locales format negatives with a different minus sign (−, U+2212) or parentheses in accounting formats. Decide whether negatives are allowed; if so, map − to -, and reject parentheses unless you support accounting input.

4. Very large values

Number loses precision above 2⁵³. For large monetary amounts (in minor units, anything above about 90 trillion), parse into BigInt or a decimal library, and send strings to the server.

5. Server contract

Send the integer minor units plus the currency code, or a decimal string in a fixed format ("1234.56") — never a locale-formatted string. Validate the same rules server-side, per sharing one Zod schema between client and server.

Minor units by currency US dollars have two minor units; 12.34 dollars is stored as 1234. Euros have two; 12.34 euros is stored as 1234. Japanese yen have zero; 1234 yen is stored as 1234. Kuwaiti dinar have three; 12.345 dinar is stored as 12345. The number of minor units comes from Intl's resolved options for a currency formatter. Currency Minor units Display (en-US) Stored USD 2 $12.34 1234 EUR 2 €12.34 1234 JPY 0 ¥1,234 1234 KWD 3 KWD 12.345 12345

Verification checklist


Frequently Asked Questions

Is there really no Intl parser?

Correct — ECMAScript’s Intl formats but does not parse numbers. Libraries exist that build parsers from locale data; the formatToParts approach here covers the common cases with no dependency.

Should I use type="number" for amounts?

No. Browsers parse type="number" inconsistently across locales, reject grouping separators, and change values on scroll. Use type="text" with inputmode="decimal".

How do I show the currency symbol?

Put the currency code or symbol outside the input as a visible prefix or suffix, and include it in the label (“Amount (EUR)”). Keeping it outside the editable text avoids caret and parsing complications.


Related

← Input Masking and Formatting