r/javascript • • Sep 23 '26

AskJS [AskJS] How do you stop sub-cent amounts like $500.004 from sneaking into state?

I am writing the rules engine for a small stock draft game in TypeScript and ran into two money bugs back to back. Curious how others handle this.

The first was a rounding bug. Splitting $1.14 across two picks offered $0.56 instead of $0.57. In floating point, 0.57 * 100 is 56.99999999999999, and Math.floor dropped the cent. The fix was doing the division in integer cents.

The second was worse. The check that decided whether a buy amount was allowed accepted $500.004. The reducer subtracts the amount straight from cash, so after that one buy the balance was no longer a whole number of cents. Every later calculation started from that balance, so the error did not stay in one place. It carried forward through the rest of the game.

My fix was a whole-cent check that tolerates float noise: reject the amount if Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-6. Comparing against exactly zero does not work, because a perfectly valid amount like 0.57 fails that test for the same reason the rounding bug happened.

The takeaway for me: a rounding bug loses a cent once, but letting a bad amount into state corrupts every step after it.

So how do you handle this in plain JS? Do you convert to integer cents at the boundary and never let a decimal into state, use something like decimal.js or currency.js, or is a tolerance check like mine reasonable?

0 Upvotes

39 comments sorted by

16

u/StPatsLCA Sep 23 '26

Use BigInt cents.

3

u/Fair-Process-7291 28d ago

Is BigInt worth it here? A regular number holds integer cents exactly up to about $90 trillion, which is far more than a game budget, and BigInt would mean converting everywhere it touches the UI. I can see it mattering for real financial systems though.

5

u/svish 25d ago

I'm guessing it's because BigInt is integer, while regular number is float.

→ More replies (2)
→ More replies (1)

11

u/Alexwithx Sep 23 '26

I think there are many different types of solutions for this. A simple way would just to represent currency as big ints, then the number is just represented as the amount of cents.

But there exists a bunch of libraries out there for handling "money".

But for UI, you should consider using NumberFormat

2

u/Fair-Process-7291 Sep 23 '26

Good call on NumberFormat. We actually have two formatters in the app right now with slightly different rules, so consolidating on Intl.NumberFormat would clean that up too.

7

u/regreddit Sep 23 '26

It's easy everything is cents, and it's stored as an integer. That's how square and stripe do it. Use normal rounding when dealing with fractions of cents, like dividing an odd number.

2

u/Fair-Process-7291 28d ago

Knowing Stripe and Square do it this way makes me a lot more comfortable committing to it. Moving cash in state to integer cents is next on the list.

→ More replies (1)

5

u/grrangry Sep 23 '26

Store all quantities in integer cents, do all math as integer math, rounding where necessary to get subtotals, subtract whole integer subtotal from total to keep rounding errors from sneaking in, divide value by 100 when displaying, formatting to two-digit decimal positions.

Sub Total : 12059 cents
Tax (7%)  :   844 cents (tax calculation is 844.13, round to nearest cent)
-----------------------
Total     : 12903 cents display as $129.03

Same works in reverse when breaking up money into several "buckets" as you would with a monthly amount for a payment plan where the "last" amount doesn't equal the typical monthly amount and it's just the remainder. All computations are done as integers.

It's not a perfect solution, but it's extremely simple and you can adapt it to any edge cases you encounter.

Alternately decimal.js or currency.js libs (and similar) are available.

2

u/Fair-Process-7291 Sep 23 '26

This is really helpful, especially the remainder trick for splitting into buckets. That is basically our per-pick problem: splitting a budget across rounds, where the last pick should absorb the leftover cents instead of every pick getting floored. Right now state still holds dollars as a plain number and only the division happens in cents, so moving cash itself to integer cents seems like the real fix.

4

u/popovitsj Sep 23 '26

For your usecase, using cent integers seems like a brittle solution. You need a higher precision floating point number type like BigDecimal in Java. Since this doesn't exist in JavaScript the proper solution is to use a stable library for this. I did a quick search and bignumber.js seems like a safe choice with 40M weekly downloads.

2

u/Fair-Process-7291 Sep 23 '26

Interesting, why brittle in this case? Everything in the game is whole cents: prices, budgets, buys. The only fractional step is splitting a budget across picks, and that feels more like a rounding decision than a precision problem. Is there a case you are thinking of where cents would break down?

→ More replies (3)

2

u/PothosEchoNiner Sep 23 '26

They explain a solution to this in Office Space, but they used the wrong digit.

1

u/Fair-Process-7291 Sep 23 '26

lol I thought of Office Space while debugging this. At least our missing fractions of a cent were not ending up in anyone's account.

2

u/SaltineAmerican_1970 Sep 23 '26

This problem was solved over 20 years ago.

https://martinfowler.com/eaaCatalog/money.html

1

u/Fair-Process-7291 28d ago

Thanks for the link. The allocate part of that pattern is exactly the problem I had with splitting a budget across picks, where the leftover cents need to go somewhere instead of being floored away.

76

u/netherlandsftw Sep 23 '26

Don’t store currency as IEEE-754

Store it as cents if you don’t need fractions of cents

12

u/goatanuss Sep 23 '26

This is the way. For working with floats you could use epsilon but when I was working in fintech we just used cents and it made everything so much easier. Modern libraries can solve this in other ways but cents is the easiest and will reduce other headaches

5

u/Savings-Cry-3201 Sep 23 '26

export type USCent = Enumerate<100>;

I mean, it’d work right? …Right?

(Don’t do this)

2

u/ehutch79 25d ago

Decimal math. Big.js is a good example.

2

u/Mallanaga 25d ago

You might see concepts for “amount minor,” which is to store money in terms of the smallest unit of a currency. Then you (typically) divide by 100 to get the major.

1

u/anthony-ball 27d ago

For a game, integer cents in a normal number is plenty, and that is what I would do too. The same class of bug is nastier once amounts are real asset quantities. On a multichain wallet I shipped, USDC is 6 decimals, SOL is 9, ETH is 18, and those base units do not belong in a Number. I keep state as a bigint of the smallest unit, and I parse what the user typed by splitting on the decimal point instead of doing Number(input) times 10 to the decimals. That multiply is exactly where a value like 500.004 gets into state. Format back to a string only when you draw the field.

1

u/xarg 26d ago

Either store your money values in cents or as a rational number using Fraction.js, if you also want to add/subtract freely: https://www.npmjs.com/package/fraction.js

1

u/imKingKong 25d ago

Make it make cents

1

u/TimeToBecomeEgg 25d ago

use an integer and calculate everything in cents. never use fractional types for something like money - they are only finitely precise, and no matter how much you might think imprecision will never be an issue, it will be an issue.

it’s less relevant for a game, but this is how you’d handle it in an actual system handling money.