10 min read·Run it here

Money in Any Currency

An integer is not an amount. Put the exponent in the currency, refuse to add two codes, and convert with one rounding at the end that does not pretend the trip back is free.

Pattern
The code owns the exponent · never add two codes · round half away from zero
Time
O(1) per amount, O(n) per sum
Space
O(1)

1234 is not an amount. It is $12.34, and it is also 1234 yen, and it is also 1.234 Kuwaiti dinars, and nothing in the integer tells you which. Every multi-currency bug in production is somebody who wrote amount / 100 in one place and meant it, and then the product shipped to Japan.

The recipe

Say before you type: "The exponent belongs to the currency, not to the codebase. Two amounts only add when the codes match. And one rounding, at the end, half away from zero."

Integer minor units, always

Amounts move between systems as integers in the smallest unit of the currency. Never a float, never a decimal string in arithmetic, never a Number that has been through a division. The integer is the amount and the code is the unit.

class Money {
  amount: number;    // integer minor units
  currency: string;  // the ISO code, uppercase
}

Normalise the code once, in one small static that every door calls: the constructor, exponent, parse, convertTo. APIs tend to speak lowercase and the standard is uppercase, and if you do not fix that at the door you will be writing toUpperCase() inside a comparison at three in the morning. Uppercasing in the constructor alone is the version that looks done and is not: Money.exponent("jpy") still misses the table, and the class now has two front doors that disagree about what a currency code is.

The exponent is a table, not a constant

const EXPONENTS = new Map<string, number>([
  ["USD", 2], ["EUR", 2],
  ["JPY", 0], ["KRW", 0],
  ["KWD", 3], ["BHD", 3],
]);

Two decimal places is the common case, not the rule. Yen and won have none: the smallest unit is the unit, so 1234 minor units is 1234 yen and dividing by 100 undercounts by a factor of a hundred. Kuwaiti and Bahraini dinars have three, so 1234 minor units is 1.234, and multiplying by 100 overcounts by ten.

The dangerous line is the default:

const exponent = EXPONENTS.get(code) ?? 2;   // no

An unknown code has to throw. Defaulting to 2 does not fail loudly on a currency you have not added yet. It produces a plausible-looking number that is wrong by a factor of 10 or 100, on real charges, and nothing in the log says so. Say that out loud: "unknown currency is a RangeError, because a wrong exponent is silent and a missing currency is not."

One aside worth having ready: some processors will not accept an arbitrary number of minor units in a three decimal currency and require the last digit to be zero, so the charge is always a whole number of hundredths even though the exponent is 3. It is not part of this drill, but knowing it is is a tell that you have actually shipped to those markets.

Format the magnitude, then put the sign back

const sign = this.amount < 0 ? "-" : "";
const abs = Math.abs(this.amount);
if (exponent === 0) return code + " " + sign + String(abs);

const scale = 10 ** exponent;
const whole = Math.floor(abs / scale);
const fraction = abs - whole * scale;
return code + " " + sign + String(whole) + "." + String(fraction).padStart(exponent, "0");

Two things are load bearing and both are about small negatives.

Peel the sign off first. The obvious version is Math.trunc(amount / scale) + "." + (amount % scale), and for -5 cents that is -0 and -5, which prints 0.-5. Every currency formatter that has ever shipped this bug shipped it because the test fixture was -1250 and not -5.

Pad the fraction to exactly exponent digits. 5 in a three decimal currency is 0.005, not 0.5, and the difference is two orders of magnitude.

And nothing is divided until something is printed. format is the only place a decimal point appears in the whole class.

Parse the digits, not the float

const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text.trim());
if (!match) throw new RangeError("not a plain decimal amount: " + text);

const fraction = match[3] ?? "";
if (fraction.length > exponent) throw new RangeError("too many decimal places");

const minor = Number(match[2]) * 10 ** exponent + Number(fraction.padEnd(exponent, "0") || "0");

The version everyone writes first is Math.round(parseFloat(text) * 100), and it is wrong twice over. It hardcodes the exponent, and it silently accepts precision the currency cannot hold: parseFloat("1.005") is a real number and rounds to some number of cents, so a tenth of a cent that should have been a loud error becomes a rounding difference in a reconciliation report three weeks later.

Fewer digits than the exponent is fine and pads out. "12" in USD is 1200, not 12. More digits than the exponent is a RangeError, because there is nowhere to put them.

Two integers add. Two amounts may not.

This is the whole reason the class exists.

plus(other: Money): Money {
  if (this.currency !== other.currency) {
    throw new TypeError("currency mismatch: " + this.currency + " and " + other.currency);
  }
  return new Money(this.amount + other.amount, this.currency);
}

The language will happily add a dollar amount to a yen amount, because both are numbers. The type has to refuse, and the refusal is the feature. A function that returns null on a mismatch just moves the crash somewhere with less context.

Now the sharp part. Compare the codes, not the exponents.

if (Money.exponent(this.currency) !== Money.exponent(other.currency))   // no

That passes every test you would think to write, because the test you think to write is USD against JPY and those have different exponents. It also adds euros to dollars, forever, silently, and the totals look completely reasonable. If you say one sentence in this whole problem, say this one: "USD and EUR are both two decimal currencies, so an exponent check is not a currency check."

There is no zero

static sum(list: Money[]): Money {
  if (list.length === 0) {
    throw new RangeError("sum needs at least one amount: an empty list has no currency");
  }
  const code = list[0].currency;
  ...
}

sum([]) cannot return zero, because a zero has to be a zero of something. The reflex is list.reduce((a, b) => a.plus(b), new Money(0, "USD")), and that seed poisons everything downstream of it: every empty list in the system quietly becomes dollars, and a list of yen either throws or silently converts depending on how careless the plus is.

The currency comes from the list. An empty list simply has no answer, and saying so is not a failure of the design.

The other half of that method is what it hands back. plus and sum both return a new Money, never one of the inputs. Returning list[0] for a list of one, or other when the receiver happens to be zero, is a shortcut that produces the right number and the wrong object: the total is now an alias of something the caller still holds, and the next person to add a mutating method to this class gets a bug with no obvious author.

Conversion: the exponents do not cancel

A rate is quoted major unit to major unit. One dollar buys 150 yen, so the rate is 150. That is the number a human gives you and the number an FX feed publishes, and it is not in minor units.

const shift = targetExponent - sourceExponent;
const scaled = this.amount * rate;
const value = shift >= 0 ? scaled * 10 ** shift : scaled / 10 ** -shift;
return new Money(Money.roundHalfUp(value), currency);

amount * rate leaves you in the source currency's scale. USD 1.00 is 100 minor units, times 150 is 15000, and 15000 yen is a hundred times the money. The shift by 10 ** (targetExponent - sourceExponent) is what moves it to the target's scale, and going from 2 decimals to 0 it is a division by 100.

Say it as a sentence: "the rate is in major units, my amounts are in minor units, so the exponent difference has to come out somewhere."

One rounding, at the end, in the target currency's minor unit. Rounding in the middle, or rounding the rate, or rounding to cents before rounding to yen, all compound.

Half away from zero, because refunds exist

static roundHalfUp(value: number): number {
  const rounded = value < 0 ? -Math.round(-value) : Math.round(value);
  return rounded === 0 ? 0 : rounded;
}

Math.round rounds a half toward positive infinity. Math.round(2.5) is 3 and Math.round(-2.5) is -2. So it agrees with you on every charge and disagrees on every refund, which is precisely the half of the traffic that has no test.

Half away from zero makes the rule symmetric: the refund of a charge rounds to the same magnitude the charge did. Round the magnitude and restore the sign, and normalise the -0 that falls out, because -0 is a real IEEE value and Object.is(-0, 0) is false.

And never truncate. Math.trunc is not a rounding mode, it is a bias, and it is always in your favour, which is the kind of arithmetic that arrives as a letter rather than a bug report.

The trip back is not free, and you do not get to pretend

JPY 50   ->  USD 34   at 0.0067
USD 34   ->  JPY 51   at 149.25

Fifty yen in, fifty one yen out. Nothing here is broken. Half a cent is worth most of a yen, so the rounding that had to happen on the way down cannot be undone on the way back up. No cache, no epsilon, no "if it round trips, return the original" makes that untrue, and every one of those attempts is a much worse bug than the one cent it was hiding.

The design answer is the one to give: if a caller needs the original amount, store the original amount. A converted value is a derived value. Keep the source currency, the source amount, the rate and the timestamp on the record, and derive the rest. A round trip is not storage.

What the interviewer is listening for

Three sentences, and every one of them is a bug you have seen:

  • Does the exponent come from the currency? Anyone can write / 100. Knowing that JPY is 0 and KWD is 3 and that an unknown code must throw is the difference.
  • Do you compare codes or exponents? The exponent check is the plausible wrong answer, and it is the one that survives code review.
  • Will you say the round trip is lossy? The candidate who admits the conversion is not reversible, and then says where the original should live instead, has clearly had to explain a one cent difference to somebody.