Money in JavaScript without float errors: integer cents, parsing and display
JavaScript has one number type, a 64-bit binary float, and most decimal fractions have no exact binary form. That is harmless for a progress bar and a problem for a price. This is what a payment form runs into:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(19.99 * 100); // 1998.9999999999998
console.log((1.005).toFixed(2)); // "1.00", not "1.01"
console.log(Math.round(1.005 * 100)); // 100, not 101
console.log(Array(10).fill(0.1).reduce((a, b) => a + b)); // 0.9999999999999999
The cause is storage. The literal 1.005 is kept as the nearest double, 1.00499999999999989342, so every later step starts from a value slightly below what the user typed.
I work on the web side of Crypto Exchange Alicante, a crypto exchange in Alicante, Spain, and the amount field is where a one-cent slip is hardest to explain to a user. The approach below needs no library. Money stays an integer inside the app, and it is converted twice: once when text comes in, once when a number goes out to the screen.
Store the smallest unit as a BigInt
EUR has two decimals, so 19.99 EUR is the integer 1999 (cents). A Number holds integers exactly up to Number.MAX_SAFE_INTEGER, which is 9007199254740991. Read as euro cents, that is about 90 trillion euros, far more than a form needs.
Crypto amounts break that margin quickly. One ether is 10**18 wei, and a Number cannot tell 1 ETH from 1 ETH plus 1 wei:
console.log(Number(10n ** 18n + 1n) === 1e18); // true, the extra wei is lost
console.log(10n ** 18n + 1n === 10n ** 18n); // false, BigInt keeps it
So every amount in the code below is a BigInt. There is no rule to remember about which values are small enough.
Parse the input as text
parseFloat("12,50") returns 12, because the comma stops the parse. A user in Spain types a comma, a user in Ireland types a dot, and some paste a number with spaces in it. The parser reads the string itself and never creates a float:
function parseAmount(input, decimals = 2) {
const text = String(input).replace(/[\s ]/g, "");
const m = /^(\d+)(?:[.,](\d+))?$/.exec(text);
if (!m) throw new RangeError(`not an amount: ${JSON.stringify(input)}`);
const [, whole, frac = ""] = m;
if (frac.length > decimals) throw new RangeError(`too many decimals: ${input}`);
return BigInt(whole + frac.padEnd(decimals, "0"));
}
parseAmount("12,50"); // 1250n
parseAmount("12.5"); // 1250n
parseAmount("1 234,5"); // 123450n
parseAmount("0,07"); // 7n
Ambiguous input throws instead of being guessed. "1.234" has three digits after the separator, so with two decimals it is rejected, and "1,234.50" has two separators, so it is rejected too. Under the field, a format hint such as "12.50 or 12,50" does more for the user than a silent guess.
Do the arithmetic on integers
Addition is plain +. Percentages need a rounding rule, and the rule belongs in the code, not in whatever toFixed does today. This helper takes basis points (1% is 100) and rounds half up for amounts that are not negative:
function percentOf(minor, basisPoints) {
if (minor < 0n) throw new RangeError("negative amounts need their own rounding rule");
return (minor * BigInt(basisPoints) + 5000n) / 10000n;
}
const net = parseAmount("19.99"); // 1999n
const vat = percentOf(net, 2100); // 420n, the 21% standard Spanish VAT rate
const total = net + vat; // 2419n, which is 24.19
Splitting a total has the same problem. 100.00 EUR in three installments cannot be three equal cent amounts, so the leftover cent has to go somewhere:
function split(total, parts) {
const n = BigInt(parts);
const base = total / n;
const extra = Number(total % n);
return Array.from({ length: parts }, (_, i) => (i < extra ? base + 1n : base));
}
split(10000n, 3); // [3334n, 3333n, 3333n]
The shares add up to the total by construction. With floats, three payments of (100 / 3).toFixed(2) come to 99.99.
Format only at the edge
Formatting is the second conversion. Intl.NumberFormat already knows the grouping and the currency position of each locale, so the code only hands it a number:
const MAX_DISPLAY = 2n ** 52n; // about 45 trillion euros in cents
function formatAmount(minor, locale = "en-IE", currency = "EUR") {
if (minor >= MAX_DISPLAY || minor <= -MAX_DISPLAY) throw new RangeError("too large to display");
const nf = new Intl.NumberFormat(locale, { style: "currency", currency });
return nf.format(Number(minor) / 100);
}
formatAmount(123450n, "en-IE"); // "€1,234.50"
formatAmount(123450n, "de-DE"); // "1.234,50 €"
formatAmount(123450n, "es-ES"); // "1234,50 €"
formatAmount(1234550n, "es-ES"); // "12.345,50 €"
The division by 100 is safe because the integer stays below 2**52. I compared the formatted result with exact string formatting on about two million values under that limit and found no difference. Above 2**52 the same comparison fails for some values, which is the reason for the guard. The 100 belongs to EUR; a currency with another number of decimals needs its own divisor.
Two details in that output are easy to miss. In Node 24, es-ES leaves out the group separator for four-digit integers, so 1234,50 € has no dot while 12.345,50 € does. And the space before the euro sign is a non-breaking space, U+00A0, so a test that compares against a regular space fails.
A few asserts keep the four helpers honest. Put them in one .mjs file with the helpers and run it with node; no output means every assert passed:
import assert from "node:assert/strict";
assert.equal(parseAmount("12,50"), 1250n);
assert.throws(() => parseAmount("1.234"), RangeError);
assert.equal(percentOf(parseAmount("19.99"), 2100), 420n);
assert.equal(split(10000n, 3).reduce((a, b) => a + b), 10000n);
assert.equal(formatAmount(123450n, "es-ES"), "1234,50 €");
The rules I keep for amount fields:
- inside the app an amount is an integer in the smallest unit, stored as a BigInt;
- text is converted in one function on the way in and in one function on the way out;
- every division and every percentage names its rounding rule;
- input that can be read two ways is rejected.
All Rights Reserved