Numbers and BigInt

Mental model: Every number is a 64 bit float, so precision is finite and you plan for it.

Level: beginner · about 12 minutes

console.log(0.1 + 0.2);          // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);  // false
console.log(0.3 - 0.1);          // 0.19999999999999998

The most reported non-bug in the language.

JavaScript has one number type: an IEEE-754 double, 64 bits, the same format C and Java call double. It stores values in binary, and 0.1 has no exact binary representation, exactly as 1/3 has no exact decimal one. You are seeing the rounding, not a bug.

Comparing with a tolerance

const nearlyEqual = (a, b, eps = Number.EPSILON) => Math.abs(a - b) < eps;

console.log(Number.EPSILON);                 // 2.220446049250313e-16
console.log(nearlyEqual(0.1 + 0.2, 0.3));    // true
console.log(nearlyEqual(1, 1.0000001));      // false

Where integers stop being safe

console.log(Number.MAX_SAFE_INTEGER);    // 9007199254740991 (2**53 - 1)
console.log(9007199254740992 === 9007199254740993); // true, they collapse
console.log(Number.isSafeInteger(2 ** 53));         // false
const big = 9007199254740993n;         // the n suffix makes it a BigInt
console.log(typeof big);               // 'bigint'
console.log(big + 1n);                 // 9007199254740994n

try {
  console.log(big + 1);                // mixing is deliberate friction
} catch (err) {
  console.log(err.name);               // 'TypeError'
}

BigInt: exact integers, any size, and no mixing.

  • Use BigInt for database ids, cryptography, and counters that can exceed 2^53.
  • Do not use it for money: it has no fractions, and 10n / 3n truncates to 3n.
  • JSON.stringify throws on a BigInt, so serialise it as a string yourself.
  • Comparisons across types still work: 10n == 10 is true, 10n === 10 is false.

Infinity and NaN

console.log(1 / 0, -1 / 0);        // Infinity -Infinity
console.log(0 / 0);                // NaN
console.log(NaN === NaN);          // false, the only value unequal to itself
console.log(Number.isNaN('abc'));  // false, it does not convert
console.log(isNaN('abc'));         // true, the old global does convert

Turning text into numbers

ExpressionResultBehaviour
Number('42px')NaNall or nothing
parseInt('42px', 10)42reads a prefix, stops at the first bad character
parseFloat('3.9kg')3.9like parseInt, but keeps decimals
Number('')0the surprise: an empty string becomes zero
+' 12 '12unary plus, same rules as Number
parseInt('08')8safe today, but always pass the radix: parseInt(s, 10)
console.log(Number('42'), Number('42px'), Number(''));  // 42 NaN 0
console.log(parseInt('42px', 10), parseFloat('3.9kg'));  // 42 3.9
console.log(+'  7  ', Number(true), Number(null));       // 7 1 0
console.log(Number(undefined));                          // NaN

Formatting, and the toFixed trap

console.log((1.005).toFixed(2));   // '1.00', because 1.005 is really 1.00499...
console.log(typeof (1.5).toFixed(1)); // 'string', not a number
console.log((1234.5678).toFixed(2));  // '1234.57'
console.log(1_000_000 + 1);           // 1000001, separators are just readability
`Number.isInteger(x)`
true for 4 and 4.0, false for 4.5 and for "4"
`Number.isFinite(x)`
no conversion, so Number.isFinite("4") is false
`Number.isNaN(x)`
no conversion, unlike the global isNaN
`Number.isSafeInteger(x)`
integer and within plus or minus 2^53 - 1
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);

Past 2^53 the representable numbers are two apart, so both sums round to the same double, 9007199254740992. That is what "safe integer" means: below the limit, every integer has its own representation and arithmetic is exact. Above it, use BigInt.

Try it yourself

Money without floats

// Work in pence, convert only when you display.
const pence = (pounds) => Math.round(pounds * 100);
const format = (p) => `\u00a3${(p / 100).toFixed(2)}`;

const basket = [19.99, 4.5, 0.1, 0.2];
const total = basket.reduce((sum, item) => sum + pence(item), 0);

console.log('float total:', basket.reduce((s, n) => s + n, 0)); // 24.790000000000003
console.log('integer total:', total, format(total));

Add a 20 percent tax step. Then try the same total with plain floats and compare the last digits.

Exercises

Compare floats safely

Write nearlyEqual(a, b, epsilon = Number.EPSILON) which returns true when the two numbers are within epsilon of each other, and false otherwise. It must return a boolean, and NaN must never be close to anything.

A stricter toNumber

Write toNumber(value) that converts a value to a number, but refuses partial text. For strings: trim first, and return NaN if what is left is empty. Anything else goes through Number. So "12px" is NaN, and "" is NaN rather than 0.

Check yourself

What does this log?
0 — Number treats an empty or whitespace-only string as 0, so the sum is 0. This is the conversion that silently turns a blank form field into a real zero, which is why a stricter wrapper is worth writing.
Which of these returns a number rather than a string?
Number('1.5') — toFixed formats for display and always returns a string, which is the classic source of "1.50" + 1 === "1.501". If you need a number back, convert again with Number(...) or a unary +.
You need exact arithmetic on integers larger than 2^53. What do you reach for?
BigInt — BigInt stores integers exactly at any size. The cost is that you cannot mix it with number in arithmetic, it has no fractions, and JSON.stringify refuses it, so ids usually travel as strings.

Common mistakes

  • Comparing floats with ===, especially money. Use integer minor units or a tolerance.
  • Using the global isNaN, which converts first, instead of Number.isNaN.
  • Forgetting toFixed returns a string, then concatenating instead of adding.

Takeaways

  • There is one number type: a 64 bit IEEE-754 double, so precision is finite.
  • Compare floats with a tolerance, or avoid the problem by storing integer minor units.
  • NaN is a number and is unequal to itself. Detect it with Number.isNaN.
  • Number is all or nothing, parseInt and parseFloat read a prefix, and toFixed returns a string.
  • Integers are exact up to 2^53 - 1. Beyond that, use BigInt and accept that it does not mix.