Shipmind Labs

A money field should refuse an ambiguous amount, not guess it

· 9 min read

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:

typescript
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:

typescript
/** 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:

typescript
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 lookup

A 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:

typescript
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:

typescript
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:

typescript
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:

text
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:

tsx
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.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com