A $327 Million Type Error
In 2022, a major fintech platform processed a bulk transfer of 32,700,000.00. The amount was stored as a plain number. A downstream service assumed it was in cents. It was not.
The bug survived code review, unit tests, and integration tests. The type system saw number and number and called it a day. Two identical types, perfectly compatible, catastrophically wrong.
This is the currency problem in a nutshell. Your type system prevents adding a string to an integer. It does not prevent adding Japanese yen to US dollars, or treating a major-unit amount as a minor-unit amount. Those are semantically different quantities, but in TypeScript, Rust, Go, and most mainstream languages, they share a single type.
Why Native Number Types Fail at Money
number, f64, int, BigDecimal. They all encode magnitude, not meaning.
A number can represent 100 dollars, 100 cents, or 100 euros. The type system treats all three as identical. You can add them, compare them, and pass them to any function that expects a number without a single complaint.
function processPayment(amount: number) {
// Is this dollars? Cents? Euros?
// The type system does not know, so it cannot help you.
}
const usd = 100 // 100 USD
const cents = 10000 // 10000 cents, also 100 USD
const eur = 100 // 100 EUR
processPayment(usd + eur) // Compiles. Wrong.
processPayment(usd + cents) // Compiles. Also wrong.
The standard defense is naming conventions. Call the variable amountInCents or amountUsd. That works until someone refactors, copies a value across a boundary, or simply misreads the name. Comments and naming conventions are not enforceable.
What you need is a type that encodes both the numeric value and the currency unit. The compiler should reject USD + EUR the same way it rejects string + number.
Phantom Types: Teaching the Compiler About Currency
The technique that works is called a phantom type. You define a generic wrapper around a number where the type parameter carries the currency label. The type parameter never appears at runtime, but the compiler uses it to enforce constraints.
Here is a complete, working implementation in TypeScript:
// money.ts
declare const brand: unique symbol;
type Currency = "USD" | "EUR" | "JPY" | "GBP";
type Money<C extends Currency> = number & {
readonly [brand]: C;
};
function money<C extends Currency>(amount: number, _currency: C): Money<C> {
return amount as Money<C>;
}
function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
return (a + b) as Money<C>;
}
function subtract<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
return (a - b) as Money<C>;
}
The brand symbol creates a nominal type distinction. Two Money values with different currency brands are incompatible, even though both are number underneath.
Usage looks like this:
const price = money(100, "USD");
const tax = money(8.5, "USD");
const total = add(price, tax); // Money<"USD">, OK
const invoice = money(500, "EUR");
const wrong = add(price, invoice);
// Error: Argument of type 'Money<"EUR">' is not assignable
// to parameter of type 'Money<"USD">'.
The compiler now understands that USD and EUR are different things. You cannot add them by accident. You cannot pass EUR to a function expecting USD. The error surfaces at the call site, not in a production ledger.
Handling Conversion Explicitly
A type system that prevents all mixing would be unusable. Real systems convert currencies all the time. The trick is to make conversion explicit, tracked, and auditable.
type ExchangeRate<From extends Currency, To extends Currency> = {
readonly from: From;
readonly to: To;
readonly rate: number;
};
function convert<From extends Currency, To extends Currency>(
amount: Money<From>,
rate: ExchangeRate<From, To>
): Money<To> {
return (amount * rate.rate) as Money<To>;
}
Now currency conversion is a first-class operation with a type-level paper trail. You cannot convert without an ExchangeRate, and the rate itself is typed with source and destination currencies.
const usdToEur: ExchangeRate<"USD", "EUR"> = {
from: "USD",
to: "EUR",
rate: 0.92,
};
const euros = convert(price, usdToEur); // Money<"EUR">
Try to use the wrong rate and the compiler stops you. Swap from and to in the rate definition and every call site using that rate becomes a type error. The bug is caught before you commit.
The Major-Unit Versus Minor-Unit Trap
Currency is not the only dimension you can model. The 2022 fintech bug was not a currency mismatch. It was a unit mismatch: dollars versus cents.
Phantom types handle this too. Add a second type parameter for the unit.
type Unit = "major" | "minor";
type Money<C extends Currency, U extends Unit> = number & {
readonly [brand]: { currency: C; unit: U };
};
function money<C extends Currency, U extends Unit>(
amount: number,
_currency: C,
_unit: U
): Money<C, U> {
return amount as Money<C, U>;
}
function toMinor<C extends Currency>(
amount: Money<C, "major">
): Money<C, "minor"> {
return (amount * 100) as Money<C, "minor">;
}
function toMajor<C extends Currency>(
amount: Money<C, "minor">
): Money<C, "major"> {
return (amount / 100) as Money<C, "major">;
}
Now the compiler tracks both currency and unit:
const dollars = money(100, "USD", "major");
const cents = money(10000, "USD", "minor");
const bad = add(dollars, cents);
// Error: 'Money<"USD", "minor">' not assignable to 'Money<"USD", "major">'
const fixed = add(dollars, toMajor(cents)); // Money<"USD", "major">, OK
The conversion functions are the only way to cross the unit boundary. Every crossing is explicit, greppable, and reviewable.
Where This Pattern Actually Hurts
Phantom types are not free. They add friction to everyday operations and they do not solve every money problem.
Serialization is the first pain point. JSON has no concept of branded types. When you JSON.stringify a Money<"USD">, you get a plain number. When you parse it back, you have a plain number. You must validate and re-brand at every boundary.
function parseMoney<C extends Currency>(
raw: number,
currency: C
): Money<C> {
if (typeof raw !== "number" || !isFinite(raw)) {
throw new Error("Invalid money value");
}
return money(raw, currency);
}
Third-party libraries are the second pain point. Most math, formatting, and database libraries expect plain number or Decimal. You will spend time writing adapter functions or wrapper types to bridge the gap.
Floating point is the third. The examples above use number, which means 0.1 + 0.2 !== 0.3. For financial software, that is unacceptable. You should store money as an integer number of minor units, or use a proper decimal library, and brand that instead of number.
import { Decimal } from "decimal.js";
type Money<C extends Currency> = Decimal & {
readonly [brand]: C;
};
A Simpler Alternative: Just Use Objects
If phantom types feel like overkill, a plain object with runtime validation gets you most of the safety with less type machinery.
type Money = {
readonly amount: number;
readonly currency: Currency;
readonly unit: Unit;
};
function add(a: Money, b: Money): Money {
if (a.currency !== b.currency || a.unit !== b.unit) {
throw new Error(
`Cannot add ${a.currency} ${a.unit} to ${b.currency} ${b.unit}`
);
}
return { ...a, amount: a.amount + b.amount };
}
This catches errors at runtime instead of compile time. The trade-off is simplicity. For internal tools, prototypes, or teams new to TypeScript, the object approach is often the right starting point. You can always add phantom types later.
Start with One Invariant
You do not need to model every currency and every unit on day one. Pick the one invariant that has burned you before, brand it, and enforce it.
If your team has shipped a dollars-versus-cents bug, start with unit tracking. If you have mixed currencies in a report, start with currency tracking. One branded type, one conversion function, one compile-time guarantee is often enough to prevent the next million-dollar mistake.
The type system will not write your exchange rate logic, handle rounding correctly, or stop you from dividing by the wrong rate. What it will do is make the accidental illegal. Two values that should never meet will refuse to compile when they do. That is worth the extra types.