At a fintech, money bugs aren’t cosmetic — they’re the product failing at its one job. The most common source of money bugs is also the most avoidable: representing currency as a floating-point number.
0.1 + 0.2 === 0.3; // false 😱(0.1 + 0.2).toFixed(2); // "0.30" — the bug is just hidden, not goneMultiply that error across millions of transactions and you get ledgers that don’t reconcile. Let’s make it impossible to represent money incorrectly.
Represent money as integer minor units
Store amounts as integers in the currency’s smallest unit — cents for USD, centavos for MXN — and always pair the amount with its currency.
export type CurrencyCode = "USD" | "MXN" | "BRL" | "COP";
export class Money { // `amount` is always an integer count of minor units (e.g. cents). private constructor( readonly amount: bigint, readonly currency: CurrencyCode, ) {}
static of(major: number, currency: CurrencyCode): Money { const minor = BigInt(Math.round(major * 100)); return new Money(minor, currency); }
plus(other: Money): Money { this.assertSameCurrency(other); return new Money(this.amount + other.amount, this.currency); }
private assertSameCurrency(other: Money): void { if (other.currency !== this.currency) { throw new CurrencyMismatchError(this.currency, other.currency); } }}Using bigint means we never overflow, and integer math means we never drift.
Make illegal states unrepresentable
The real win is the assertSameCurrency guard. Adding pesos to dollars is a
category error, and the type system plus a runtime check make it loud instead
of silent.
const subtotal = Money.of(19.99, "USD");const shipping = Money.of(4.5, "USD");const total = subtotal.plus(shipping); // ✅ $24.49
const wrong = subtotal.plus(Money.of(100, "MXN"));// ❌ throws CurrencyMismatchError — caught in tests, never in prodA crash in a test is a gift. A silent rounding error in production is a multi-day reconciliation incident.
Rounding is a policy, not an accident
When you do need to divide money — splitting a bill, computing interest — rounding has to be explicit and consistent. We use banker’s rounding and always account for the remainder so the parts sum back to the whole.
/** Split `money` into `n` parts with no lost cents. */function allocate(money: Money, n: number): Money[] { const base = money.amount / BigInt(n); const remainder = Number(money.amount % BigInt(n)); return Array.from({ length: n }, (_, i) => Money.rawMinor(base + (i < remainder ? 1n : 0n), money.currency), );}The distributed remainder guarantees the pieces add back up to the original — no phantom cent appears or disappears.
Takeaways
- Never store money as a float. Integer minor units, always.
- Pair every amount with a currency and refuse cross-currency math.
- Make rounding an explicit, tested policy.
This Money type is a few dozen lines, but it has quietly prevented an entire
class of incidents across every service we run.



