Money as a data type
DEV Community

Money as a data type

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 = { USD: 2, JPY: 0, KWD: 3 }; function toDecimalString(m: Money): string { const e = EXPONENT[m.currency]; const negative = m.minor a + r, 0n); if (sum { 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 = { readonly minor: bigint; readonly currency: C; }; const add = (a: Money , b: Money ): Money => ({ 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 = { from: F; to: T; value: string; // exact decimal, not a float quotedAt: string; // ISO 8601 source: string; // which provider, which feed }; function convert ( amount: Money , rate: Rate , mode: RoundingMode, ): Money ; 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: - BigInt is 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: - allocate always 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 float ordouble for 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. Originally published at mashhadi.me. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.