Money as a data type
12 min read
Most guides open with 0.1 + 0.2 === 0.30000000000000004 and conclude "don't use floats for money." True, and not very useful. The interesting question is what you replace it with, because "use decimals" and "use integers" are different answers that fail in different places. Neither of them addresses the bug most likely to reach production: a function that cheerfully adds 500 US dollars to 500 Japanese yen and returns 1000 of nothing.
Money isn't a number. It's a number, a currency, a scale, and a rounding policy, and if your type only carries the first one the other three end up scattered across call sites as assumptions.
Floats lose money in ways that survive your tests
The accumulation bug is the famous one:
let balance = 0;
for (let i = 0; i < 10; i++) balance += 0.1;
balance === 1; // false
balance; // 0.9999999999999999
This one is easy to catch, because the result looks obviously wrong the moment you print it. Here's the version that doesn't:
Math.round(1.005 * 100) / 100; // 1
Not 1.01. The nearest double to 1.005 is 1.00499999999999989342, so multiplying by 100 gives 100.49999999999999, and rounding that down is arithmetically correct. Round-to-nearest-cent is the single operation you would never think to unit test, and it is wrong for a value a human typed into a form.
There's also a ceiling. Doubles represent integers exactly only up to Number.MAX_SAFE_INTEGER, which is 9007199254740991. In cents that's about 90 trillion dollars, so you're fine, right up until someone stores an amount in a currency with a smaller unit or you accumulate a notional across a whole book.
Decimals fix the arithmetic, not the model
Swap in decimal.js, Java's BigDecimal, or Postgres NUMERIC and 1.005 is exactly 1.005. Every arithmetic complaint above goes away.
What doesn't go away:
new Decimal("500").plus(new Decimal("500")); // 1000
One thousand what? A Decimal carries no currency, no scale policy, and no rounding mode. You've fixed the representation and kept the hole in the type, and in my experience essentially every expensive money bug lives in that hole rather than in the third decimal place.
Store minor units, and make the exponent data
Store amounts as integer minor units (cents, satang, fils) in a BigInt. It's exact, there's no rounding at rest, and you're not near any ceiling.
The trap is what people write next:
const display = amount / 100; // wrong for a third of the world
ISO 4217 assigns every currency an exponent, and it is not always 2:
| Currency | Exponent | 1234567 minor units |
|---|---|---|
| JPY | 0 | ¥1,234,567 |
| USD | 2 | $12,345.67 |
| KWD | 3 | 1,234.567 |
| CLF | 4 | 123.4567 |
Zero-decimal currencies include JPY, KRW and VND. Three-decimal currencies include KWD, BHD, OMR, JOD and TND. So the exponent is a lookup, never a literal:
const EXPONENT: Record<Currency, number> = { USD: 2, JPY: 0, KWD: 3 };
function toDecimalString(m: Money): string {
const e = EXPONENT[m.currency];
const negative = m.minor < 0n;
const digits = (negative ? -m.minor : m.minor).toString().padStart(e + 1, "0");
const whole = digits.slice(0, digits.length - e);
const frac = e === 0 ? "" : "." + digits.slice(digits.length - e);
return `${negative ? "-" : ""}${whole}${frac}`;
}
For display, hand that string straight to Intl.NumberFormat. Its format() accepts a string, not just a number, which means you can render an exact value without round-tripping it through a double:
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
fmt.format(toDecimalString({ minor: 123456n, currency: "USD" })); // "$1,234.56"
Intl already knows each currency's exponent, so ¥1,234 renders without a decimal point and KWD 1,234.567 renders with three, for free.
Rounding is a policy decision, not a default
Round half away from zero (what most people mean by "round") is biased. Every tie goes the same direction, so the error accumulates instead of cancelling:
values 0.5 1.5 2.5 3.5 4.5 sum
exact 12.5
half-up 1 2 3 4 5 15
half-even 0 2 2 4 4 12
Half-even, also called banker's rounding, sends ties to the nearest even integer. Half of them go up, half go down, and across a large book the drift is far smaller. It's also the IEEE-754 default.
Half-even is not automatically the right answer. Tax authorities, card scheme rules and loan regulations sometimes specify a mode, and it isn't always the unbiased one. The point is that the mode is a decision that belongs in a signature:
function convert(from: Money, rate: Rate, mode: RoundingMode): Money;
not a default buried three layers down in a formatting helper.
Splitting a bill is where naive money types die
Divide $10 three ways. Each share is $3.33, and three of those is $9.99. You have lost a cent, and if the other side of that entry is a real bank transfer, your ledger no longer balances.
Division isn't the operation you want. Allocation is: hand out every minor unit, distribute the remainder deterministically, guarantee the parts sum to the whole.
export function allocate(total: bigint, ratios: bigint[]): bigint[] {
const sum = ratios.reduce((a, r) => a + r, 0n);
if (sum <= 0n) throw new RangeError("ratios must sum to a positive value");
let remainder = total;
const shares = ratios.map((r) => {
const share = (total * r) / sum; // BigInt division truncates toward zero
remainder -= share;
return share;
});
// Hand the leftover units out one at a time, in order.
const step = total < 0n ? -1n : 1n;
for (let i = 0; remainder !== 0n; i = (i + 1) % shares.length, remainder -= step) {
shares[i] += step;
}
return shares;
}
allocate(1000n, [1n, 1n, 1n]); // [334n, 333n, 333n]
allocate(500n, [3n, 7n]); // [150n, 350n]
allocate(100n, [1n, 1n, 1n, 1n, 1n, 1n, 1n]); // [15n, 15n, 14n, 14n, 14n, 14n, 14n]
That step variable exists because of a bug I wrote on the way to this post. My first version incremented by 1n unconditionally, which is correct for payments and quietly wrong for refunds: allocate(-1000n, [1n, 1n, 1n]) returned three shares of -333n, summing to -999n. A missing cent, on exactly the code path where a customer is getting money back.
This is also the reason the ordering is worth thinking about. Handing the remainder to the first participants every time is deterministic, which is what you want for reproducibility, but it isn't fair over repeated splits. If the same three accounts split odd amounts every month, rotate the starting index by something stable. An invoice number works.
The currency belongs in the type
Make the currency part of the static type and cross-currency arithmetic stops compiling:
type Currency = "USD" | "JPY" | "KWD";
type Money<C extends Currency = Currency> = {
readonly minor: bigint;
readonly currency: C;
};
const add = <C extends Currency>(a: Money<C>, b: Money<C>): Money<C> => ({
minor: a.minor + b.minor,
currency: a.currency,
});
add({ minor: 500n, currency: "USD" }, { minor: 500n, currency: "JPY" });
// ^ Type '"JPY"' is not assignable to type '"USD"'
Types are erased at runtime, though, and money arrives over HTTP from systems that have never heard of your union. So keep the runtime guard as well:
if (a.currency !== b.currency) {
throw new TypeError(`Cannot add ${a.currency} to ${b.currency}`);
}
Conversion is then a separate operation with a different shape. It is not multiplication by a number. It consumes a rate that knows where it came from, and it returns money in a different currency:
type Rate<F extends Currency, T extends Currency> = {
from: F;
to: T;
value: string; // exact decimal, not a float
quotedAt: string; // ISO 8601
source: string; // which provider, which feed
};
function convert<F extends Currency, T extends Currency>(
amount: Money<F>,
rate: Rate<F, T>,
mode: RoundingMode,
): Money<T>;
Persist the rate you actually used alongside the resulting entry. Six months later, someone reconciling a break needs to know whether the discrepancy is a bug or a rate that moved between quote and capture, and "we looked it up at the time" is not an answer.
JSON is a float in a trench coat
You can do all of the above and give it away at the boundary:
JSON.parse('{"amount": 9007199254740993}').amount; // 9007199254740992
The JSON spec doesn't bound numeric precision, but JSON.parse in every JavaScript runtime produces a double. Any consumer written in JS silently truncates whatever you sent.
Two wire formats survive the trip. Integer minor units, which is what Stripe does: "amount": 2000 means $20.00, with a published list of zero-decimal currencies so clients know how to interpret it. Or a decimal string, "amount": "12.34", which is self-describing but needs an exact parser on the other end.
One sharp edge if you go the BigInt route:
JSON.stringify({ amount: 1n });
// TypeError: Do not know how to serialize a BigInt
You need an explicit encoder. That's a feature. It forces the wire representation to be a decision someone made rather than whatever your ORM happened to emit.
What to put in the database
In Postgres, BIGINT minor units plus a currency column, or NUMERIC(19, 4). Both are exact. Pick based on whether you need sub-minor-unit precision, not on which one looks tidier.
Don't use the money type. Its fractional precision comes from lc_monetary, a server setting, so the same column can mean different things on two machines and a dump/restore across locales can change your values. The Postgres documentation itself steers you elsewhere.
Whatever you choose, the currency column travels with the amount, in the same table, non-null. A bare amount column is the same bug as a bare number, just durable. Constrain it against a currencies table that also carries the exponent, so there's exactly one place in the system that knows JPY has none.
Where this model stops working
Minor units are a settlement precision, and plenty of finance happens at a finer grain. Interest accrual, per-unit pricing, and FX rates all need more digits than the currency has. The fix isn't to abandon the model, it's to keep two scales explicitly: compute at high precision, round once at the point money actually moves, and store both the unrounded and the settled figure so the rounding is auditable.
Three more honest limits:
BigIntis meaningfully slower thannumber. Irrelevant in a request handler, possibly relevant inside a risk-scoring loop that runs a million times a second. Measure before you care.- Type-level currency only works if the currency set is closed at compile time. If yours is loaded from config, you're back to runtime checks, and the static version is a comfortable illusion.
- None of this catches a wrong rate, a wrong sign, or a posting to the wrong ledger account. It makes an entire class of representation errors impossible. It does not make you correct.
Test the invariants, not the examples
Money types are unusually well suited to property-based testing, because the rules are short and absolute:
allocatealways sums back to the total, for every total and every set of ratios- equal ratios never produce shares differing by more than one minor unit
parse(format(m))round-trips tom- addition is commutative and associative within a currency
- addition across currencies always throws
Those are five properties covering more ground than a page of hand-picked examples. The first one is what caught the refund bug earlier in this post; I ran it across roughly 23,000 generated combinations of totals and ratios, and the negative case failed immediately. No example-based test I would have thought to write covers allocate(-1000n, [1n, 1n, 1n]).
The short version
- Never a
floatordoublefor a monetary amount, anywhere in the stack. - Store integer minor units or an exact decimal.
- Look the exponent up per currency. It is not always 2.
- Put the currency inside the type, and reject cross-currency arithmetic loudly.
- Make the rounding mode a parameter, not a default.
- Allocate; don't divide.
- Cross the wire as minor units or a decimal string, never as a JSON number.
- Test invariants, not examples.
If you want a concrete place to start: grep your codebase for / 100 and * 100. Every hit is either a hardcoded currency assumption or a float round-trip, and usually both. It's an afternoon of work, and it's the highest-value refactor most financial codebases have sitting in front of them.