A money field should refuse an ambiguous amount, not guess it
Three things go wrong in every money input, and only the first one is well known. Floats: 0.1 + 0.2 is 0.30000000000000004, and a total ends up a cent off for reasons the user cannot see. Minor units: code that multiplies by 100 is wrong in Tokyo, where JPY has no decimals, and in Kuwait, where KWD has three. Separators: 1.234,56 and 1,234.56 are the same amount written by two people, and a parser that assumes one of them reads the other off by a factor of a hundred.
Those look like three unrelated bugs. They are one habit. In each case the field has incomplete information and fills the gap with a guess — that a binary float is close enough, that a currency has two decimals, that a dot is a decimal point. We have spent years building payment services, per-user ledgering and reconciliation flows, and a guess made in an input does not stay in the input: it arrives later as a discrepancy somebody has to investigate, far from the keystroke that caused it. So the rule we settled on is blunt: a money field refuses an amount it cannot read exactly. We packaged that rule as a React hook, amountfield, and the interesting engineering is not the arithmetic. It is what refusal costs in a control where most keystrokes are legitimately not a number yet.
The constraint#
Refusing is easy in a validator that runs on submit. The whole string is there, and anything that does not parse is wrong. An input is different: 12. is not wrong, it is twelve with the cents still coming. If refusal is implemented as "no amount means error", the field turns red between the . and the 5 of 12.50, and users learn to ignore it. So a strict field needs two distinct ways to have no amount, and they have to be separate in the type, not in a comment:
export type FieldState = {
/** What the input element shows. Always what the person typed, until blur. */
readonly text: string;
/** The exact amount, when the text is a complete one. */
readonly money: Money | null;
/** Why there is no amount. Null while the field is simply incomplete. */
readonly problem: ParseFailure | null;
/** True when the text is a prefix of a valid amount rather than wrong. */
readonly incomplete: boolean;
};Everything below follows from keeping problem and incomplete apart.
No float ever exists#
The amount is an exact integer count of the currency's smallest unit, carried as a bigint, together with the exponent that says how many of them make one major unit:
/** An exact amount: minor units, and how many of them make one major unit. */
export type Money = {
/** Cents, satoshi, yen — whatever the currency's smallest unit is. */
readonly minor: bigint;
readonly currency: string;
readonly exponent: number;
};Parsing works on the digits as text: 12.34 becomes 1234 by moving the decimal point, not by multiplying by a hundred. Formatting goes back through a decimal string rather than a number. There is no point in the round trip where a float could exist and round, which means the fix is structural rather than a matter of being careful.
One detail in the same spirit: the currency is the caller's and is never read out of the text. A pasted $12.34 in a EUR field is refused rather than quietly turned into euros. The field does not know what the dollar sign means, and pretending otherwise is how a price list acquires the wrong currency.
The exponent is data, and an unknown currency is refused#
The currency code is required — there is nothing sensible to fall back to — and its exponent comes from a bundled ISO 4217 table: JPY 0, KWD 3, CLF 4, and the codes most products meet. A code the table does not carry is refused rather than assumed to have two decimals, because a wrong amount is worse than an error.
That refusal is only tolerable if the escape hatch is easy, so the override takes whichever shape the caller already has:
parse("10.000", { currency: "XYZ", exponent: 3 }); // this one currency
parse("10.000", { currency: "XYZ", exponent: { XYZ: 3 } }); // your own table
parse("10.000", { currency: "XYZ", exponent: (code) => mine[code] }); // your own lookupA lookup that returns undefined falls back to the bundled table, so a product that deals in one exotic unit adds it without restating the two hundred codes that are already right. This matters more than it sounds. In crypto and cross-border work the exotic unit is not an edge case, it is the product, and a table that has to be forked in full is a table that drifts.
Separators come from the platform, and grouping is evidence#
We do not ship a table of which locale writes what. The platform already knows:
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 = parts.find((part) => part.type === "decimal")?.value ?? ".";
const group = parts.find((part) => part.type === "group")?.value ?? "";
const found = { decimal, group };
separators.set(locale, found);
return found;
}Order of operations is the whole trick. The decimal separator carries meaning, so it is split off first — before any group separator is touched, or 1.234,56 and 12.34 become indistinguishable. Whitespace is normalised into the group separator rather than deleted, because a space is grouping in several locales (fr-FR writes 1 234,56) and is a decimal point in none, so it should be judged by the grouping rule instead of silently vanishing.
And then the case that most libraries get wrong: 12.34 typed into a de-DE field. The dot is that locale's group separator, so stripping it yields 1234.00 — a hundred times the intended amount, with no error anywhere. Nine times out of ten the user meant a decimal and used the wrong key. We refuse it, and a group separator is only stripped where the digits are actually grouped: final group of three, no group longer. The reasons are part of the public type, because the caller has to render them:
export type ParseFailure =
| "empty"
| "not-a-number"
| "too-many-decimals"
| "too-many-separators"
| "bad-grouping";Unfinished is a state, not a mistake#
Here is where strictness has to be held back. The transition on each keystroke parses, and only asks about wrongness if the text also fails to be a prefix of something valid:
export function typed(text: string, options: FieldOptions): FieldState {
const result = parse(text, options);
if (result.ok) {
return { text, money: result.money, problem: null, incomplete: false };
}
// "1 2" and "" are not errors, they are unfinished. Showing a red border to
// someone who is still typing is the most common bug in these components.
if (isIncomplete(text, options)) {
return { text, money: null, problem: null, incomplete: true };
}
return { text, money: null, problem: result.reason, incomplete: false };
}12. is twelve. 12,5 in de-DE is twelve fifty with the last digit missing. 1 2 in fr-FR is a group that has not reached three digits yet. None of them is an error; all of them fail to parse.
The second restraint is that the text is never rewritten while someone is typing. Reformatting mid-entry is what makes a field jump the caret and eat a digit. Grouping is applied on blur, when the value has stopped moving and rewriting it cannot fight the person entering it:
while typing : text="1234.5" amount=123450 cents
after blur : text="1,234.50"All of this is a pure function over (text, options), and the React hook is a thin wrapper. That is not architectural taste. The awkward cases — a half-typed 12., a pasted 1 234,56, a de-DE dot — are exactly what nobody tests when the logic only exists inside a component, and they are the cases that cost money. As a state machine they are table tests that run in milliseconds, which is the difference between a testing culture we can actually enforce at review and one that stops at the component boundary.
The component itself stays small, because it returns props rather than markup:
function PriceField() {
const field = useAmountField({ currency: "EUR", locale: "de-DE" });
return (
<>
<input {...field.inputProps} />
{field.problem && <span role="alert">{field.problem}</span>}
</>
);
}Refusal does not stop at the field#
An exact amount is worth little if the next line of code multiplies it by a float. So the arithmetic keeps the same posture: add refuses to mix currencies, and multiply refuses to round behind your back. Nineteen percent of 99.99 is not a whole number of cents, and which way it goes is a business decision, so without a rounding mode it throws rather than choosing one. The ratio is text, a bigint, a whole number, or a numerator/denominator pair — the JavaScript number 0.1 is not 0.1, so it is refused with a message saying which form to write instead. allocate splits an amount into parts that sum back exactly, handing out every minor unit and inventing none: five cents in three ways is 2, 2, 1.
What it costs#
One Intl.NumberFormat per locale, built once and cached, because parsing runs on every keystroke. bigint arithmetic instead of number. And a genuine tax on the caller: an exotic currency needs an explicit exponent, and every multiplication needs a rounding mode. That tax is the point — it is paid at the call site, by the person who knows which way the business rounds, instead of being paid silently by a default that is right in most of the world and wrong in Tokyo.
The support cost is real too. A field that refuses 12.34 in a de-DE locale will generate questions, where a lenient field generates none and produces an invoice that is off by a hundred. We would rather answer the question.