Why a thousands separator is only stripped where the digits are grouped
Two people write the same amount as 1,234.56 and 1.234,56. A money input that assumes either convention reads the other one off by a factor of a hundred, and what lands in the request body looks like a perfectly ordinary number. The fix is not a larger locale table. It is an order of operations inside the parser, plus a refusal in the one case where the typed text is genuinely ambiguous.
We have spent most of the last decade on payments: processing services on card rails and account-to-account flows, hosted payment pages running under a merchant's own branding and domain, cross-border catalogs read by people in several locales at once. Amount entry is where the locale problem stops being cosmetic. A date rendered the wrong way around gets noticed by whoever reads it. An amount multiplied by a hundred is a valid amount.
The rule we settled on in amountfield, our React money input, fits in one sentence: a group separator is decoration, and decoration is removed only where the digits behind it are actually grouped.
The separators are the platform's answer, not ours#
Hard-coding "comma for thousands, dot for decimals" is wrong, and you already know it. The trap that catches more people is the correction: a table mapping locale to separators, maintained by us, inside a library. Intl already has that table, somebody else maintains it, and it ships with the runtime.
export type Separators = {
/** Between the whole part and the fraction. */
readonly decimal: string;
/** Between groups of thousands. May be empty. */
readonly group: string;
/** The locale's ten digits in order: "0123456789", or the Arabic-Indic ones. */
readonly digits: string;
/** The minus this locale writes, without the bidi marks Intl emits with it. */
readonly minus: string;
};
export function separatorsFor(locale: string): Separators {
// Parsing runs on every keystroke, and building the formatter is the
// expensive half of this function.
const known = separators.get(locale);
if (known) return known;
const parts = new Intl.NumberFormat(locale, { useGrouping: true }).formatToParts(1234567.8);
const decimal = withoutBidi(parts.find((part) => part.type === "decimal")?.value ?? ".");
const group = withoutBidi(parts.find((part) => part.type === "group")?.value ?? "");
const plain = new Intl.NumberFormat(locale, { useGrouping: false });
let written = "";
for (let digit = 0; digit <= 9; digit++) written += withoutBidi(plain.format(digit));
const sign = plain.formatToParts(-1).find((part) => part.type === "minusSign")?.value;
const found = {
decimal,
group,
// A numbering system whose digits were not one character each would make the
// mapping in asciiNumerals misread an amount rather than refuse it.
digits: written.length === 10 ? written : ASCII_DIGITS,
minus: withoutBidi(sign ?? "-") || "-",
};
separators.set(locale, found);
return found;
}Three details there earn their lines. The separators come from formatToParts(1234567.8), so the question being asked is "how does this locale write a grouped decimal number", not "what did we once write down about this locale". The digits and the minus sign get read too, because what format wrote is what a person may paste back in: an amount in a locale's own numerals is read, and its own minus is not a stray character. The whole result is memoised per locale, since constructing the formatter is the expensive part and parsing runs on every keystroke.
Look at the guard on digits. If a numbering system does not write one character per digit, we keep ASCII rather than build a mapping that would misread an amount instead of refusing it. Nearly every decision in this library has that shape. Where a rule cannot be applied safely, the outcome is an error, not a guess.
Split on the decimal before anything else is touched#
The order matters more than any individual rule, because once a group separator has been stripped, the evidence you need to decide whether it should have been stripped is gone. So parse proceeds like this:
- the text is trimmed; an empty string is
empty, and a leading-is the sign; - any whitespace becomes the group separator, since a space is a decimal point in no locale at all, so
1 234,56written by a French user reads correctly wherever the space landed; - the string is split on the decimal separator before any group separator is considered, and more than two pieces is
too-many-separators; - a group separator inside the fraction is
not-a-number; - a group separator in the whole part is stripped only when every group is digits, none longer than three, and the final one exactly three; anything else is
bad-grouping; - a fraction longer than the exponent the currency carries is
too-many-decimals; - what remains is digits, with the fraction padded to the exponent, handed to
BigInt.
The failure modes are a closed union rather than a boolean, because a field has to say different things to the person typing:
export type ParseFailure =
| "empty"
| "not-a-number"
| "too-many-decimals"
| "too-many-separators"
/**
* The group separator is there but the digits are not grouped: "12.34" in
* de-DE. Nine times out of ten this is a decimal written with the wrong
* separator, and stripping it would read the amount off by a factor of a
* hundred — so it is refused instead.
*/
| "bad-grouping";Everything happens on the digits as text, so no float ever exists: 12.34 becomes 1234 by moving the decimal point, not by multiplying. The currency belongs to the caller and is never read out of the text either, so $12.34 in a EUR field is refused rather than quietly taken for dollars.
A dot before two digits in a German field is a wrong key, not a number#
parse("1,234.56", { currency: "EUR", locale: "en-US" }); // ok: 123456 minor units
parse("1.234,56", { currency: "EUR", locale: "de-DE" }); // ok: 123456 minor units
parse("12.34", { currency: "EUR", locale: "de-DE" }); // bad-grouping
parse("12,34,567.89", { currency: "EUR", locale: "en-IN" }); // okThe fourth line explains why the rule is phrased the way it is rather than "groups of three". Grouping is not Western-style everywhere: the lakh pattern writes every group but the last with two digits. "Strip the commas" handles that by accident. "Strip the commas only where the grouping is real" has to be told that short groups ahead of a final three are still grouping, which is exactly the condition above. The currency and the locale are separate options for the same reason. The exponent comes from the currency, the separators come from the locale, and a EUR field laid out for an Indian audience is an ordinary thing to want.
The third line is what the rule costs and what it buys. 12.34 in a de-DE field is, almost always, a decimal amount typed with the wrong key, because the keyboard offered a dot. Reading it as grouping yields 1234.00, an amount a hundred times larger, with no fraction, and nothing on screen that says anything is wrong. Refusing it yields a visible error on a field the person is still looking at. That asymmetry is the whole argument: one of those outcomes is fixed in two seconds by the person who caused it, and the other is found during reconciliation by somebody else.
The field stays calm while the number is half typed#
A strict parser creates a second problem, and it is why most money inputs end up either sloppy or hostile: for most of the time somebody is typing, the text is not yet a valid amount. A field that turns red between the . and the 5 of 12.50 has made correctness the user's problem.
So "unfinished" is a separate question from "wrong", asked of the same locale rules:
assert.equal(isIncomplete("", eur), true);
assert.equal(isIncomplete("-", eur), true);
assert.equal(isIncomplete("1,", eur), true);
// "12." is twelve, so it is complete rather than unfinished.
assert.equal(isIncomplete("12.", eur), false);
assert.equal(isIncomplete("12.345", eur), false);
assert.equal(isIncomplete("abc", eur), false);
// A currency with no decimals has nothing to wait for after the separator.
assert.equal(isIncomplete("12.5", jpy), false);isIncomplete is true for the empty string and a bare sign, false as soon as a fraction is too long to ever become valid, and otherwise defers to a prefix check that allows one to three digits per group with only the last group possibly empty. The last two cases are the interesting ones. A trailing separator is not an unfinished amount, since 12. is twelve, and for a zero-exponent currency there is nothing to wait for after the separator at all.
typed in src/field.ts is where the two questions meet: text that is merely unfinished comes back as incomplete with problem: null, and anything else comes back under its ParseFailure. Nothing regroups while the digits are moving. Reformatting lives in blurred, when the value has stopped changing and rewriting it can no longer fight the person entering it.
import { useAmountField } from "amountfield";
function PriceField() {
const field = useAmountField({ currency: "EUR", locale: "de-DE" });
return (
<>
<input {...field.inputProps} />
{field.problem && <span role="alert">{field.problem}</span>}
</>
);
}What it costs to run#
The runtime cost is one Intl.NumberFormat per locale, built once and cached, plus string work on a string the length of a price. The maintenance cost is nothing, because there is no separator table here to drift out of step with CLDR.
The real cost is one refused keystroke pattern, and it is a product cost rather than an engineering one. bad-grouping is a value we hand back, not a message. Somebody has to write copy that tells the person which separator this field expects, in that field's locale, or the refusal reads as a broken input. We are making that trade deliberately: a small amount of interface work in exchange for an entire class of amount that can no longer reach a ledger.
The second cost is discipline about where the logic lives. The state machine is a pure function and the hook is a thin wrapper over it, because the awkward cases (a half-typed 12., a pasted 1 234,56, a caret in the middle of the number) are precisely the ones nobody tests when the rules only exist inside a component.
Close#
The general principle generalises past money: strip decoration only where it is provably decoration, and when one string supports two readings that differ by a factor of a hundred, refuse rather than pick. A parser that guesses is right most of the time, and the times it is wrong are indistinguishable from the times it is right. That is the property that makes it expensive.