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 spaceU+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 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
- 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. - Derive separators from
formatToParts. Formatting a sample number in the locale tells you exactly which characters mean “group” and “decimal”. - 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. - Store money as integer minor units.
1234.56euros becomes123456cents. Sums and comparisons are then exact, avoiding the floating-point drift discussed in controlled number inputs and intermediate values. - Take precision from the currency.
maximumFractionDigitson a currency formatter gives 0 for JPY and 3 for KWD; validate the user’s decimal places against it. - 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.
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.
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.