DEV Community

Shipmind Labs
Shipmind Labs

Posted on Originally published at shipmindlabs.com

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 lookup
Enter fullscreen mode Exit fullscreen mode

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on shipmindlabs.com — where we write about payment systems, infrastructure and marketplace backends.

Top comments (0)