Number quirks in JavaScript
JavaScript has a single numeric type - number - and plenty of things that trip up on it. A note on IEEE 754, NaN, precision limits, and BigInt. Why 0.1 + 0.2 !== 0.3 and why NaN === NaN is false.
You’ve probably seen something like this before:
0.1 + 0.2; // 0.30000000000000004
NaN === NaN; // false
Number.MAX_SAFE_INTEGER + 1; // 9007199254740992
Number.MAX_SAFE_INTEGER + 2; // 9007199254740992 (also!)
isNaN("abc"); // true
Number.isNaN("abc"); // falseAll these results look like bugs, but they’re deterministic. Two things sit underneath: IEEE 754 - an international standard for floating-point number representation from 1985, which JavaScript uses for the number type (specifically the 64-bit double) - plus a few deliberate decisions in ECMA-262. The rest of these quirks come from those two.
#Why 0.1 + 0.2 isn’t 0.3
Computers store numbers in binary. 0.5 can be written exactly (it’s 2^-1), but 0.1 in binary is an infinite representation - just like 1/3 in decimal is 0.333.... IEEE 754 has to cut it off somewhere - specifically at 52 bits (the so-called mantissa: the part that encodes the significant digits of the number). So it stores an approximation:
(0.1).toPrecision(20); // "0.10000000000000000555"
(0.2).toPrecision(20); // "0.20000000000000001110"The sum of two approximations is an approximation of the sum, not exact 0.3. Hence 0.30000000000000004.
This isn’t a JavaScript bug. Python, Java, C++ behave the same way - everywhere floating-point numbers are IEEE 754 doubles, which is nearly every popular language.
In practice: don’t compare floats (numbers with a decimal part - from floating point) with ===. Use a tolerance comparison:
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // trueNumber.EPSILON is the smallest difference between 1 and the next representable number (~2.22e-16). A good “close to zero” measure for 64-bit doubles.
#NaN - the only value that doesn’t equal itself
NaN stands for “Not a Number”. It’s a special value JavaScript returns when it tries to compute something with no sensible numeric result - like the square root of minus one or zero divided by zero:
0 / 0; // NaN
Math.sqrt(-1); // NaN
parseInt("abc"); // NaN
Number(undefined); // NaNWherever NaN lands in code, it propagates further - every arithmetic operation involving NaN yields NaN. The logic is simple: if one of the operands isn’t a sensible number (NaN), the result can’t be a sensible number either. NaN + 5 is still “something-without-meaning”. IEEE 754 chose this deliberately - you can check for NaN once at the end of a computation, instead of after every operation. But there are two things to remember.
First, NaN !== NaN. The IEEE 754 spec says it directly: NaN isn’t equal to anything, including itself. It’s the only value in JS with this property.
NaN === NaN; // false
NaN == NaN; // falseSecond, Number.isNaN() ≠ global isNaN(). Global isNaN(x) first converts the argument via ToNumber (the mechanism I covered in the coercion note), and only then tests it. This causes false positives:
isNaN("abc"); // true - "abc" converts to NaN
isNaN({}); // true - {} converts to NaN
Number.isNaN("abc"); // false - "abc" is a string, not NaN
Number.isNaN(NaN); // true - the only case where it returns trueTo check for NaN, use Number.isNaN(), never the global isNaN(). The global one exists only for backward compatibility.
#Safe integers and BigInt
64-bit IEEE 754 doubles represent integers exactly only up to 2^53 - 1 (Number.MAX_SAFE_INTEGER). Above that limit, precision starts breaking down:
Number.MAX_SAFE_INTEGER; // 9007199254740991 (2^53 - 1)
Number.MAX_SAFE_INTEGER + 1; // 9007199254740992 (still ok)
Number.MAX_SAFE_INTEGER + 2; // 9007199254740992 (!) - not 9007199254740993
Number.MAX_SAFE_INTEGER + 3; // 9007199254740994Where does this come from? Above 2^53 there are more integers than possible binary representations - the format has to round to the nearest one it can encode. Just past the boundary, representable numbers go in steps of 2 (2^53, 2^53 + 2, 2^53 + 4…) - that’s why +1 may not change the value at all. The non-existent 2^53 + 1 rounds down to 2^53. Above 2^54 the steps grow to 4, then 8.
For most code it doesn’t matter. But for IDs longer than ~16 digits (snowflake IDs like Twitter/Discord, values from BIGINT columns in a database), nanosecond timestamps, or very large financial amounts - it’s a real bug that silently corrupts data.
The solution: BigInt (ES2020). A separate primitive type for integers of arbitrary precision.
const big = 9007199254740993n; // suffix `n` = BigInt literal
big + 1n; // 9007199254740994n - exactly
typeof big; // "bigint" - separate type
BigInt("9007199254740993"); // also BigIntThe main BigInt pitfall: it doesn’t mix with Number without explicit conversion. 1n + 1 throws a TypeError. You either need Number(big) + 1 or big + 1n. Smaller things: JSON.stringify(1n) throws (you have to convert to a string), and >>> (unsigned right shift) doesn’t work on BigInt.
#Object.is - when === and isNaN aren’t enough
Object.is(a, b) is a built-in method for comparing two values - an alternative to ===, but with two corrections:
NaN === NaN; // false -> Object.is(NaN, NaN) === true
0 === -0; // true -> Object.is(0, -0) === falseSo: Object.is treats NaN as equal to itself and distinguishes +0 from -0. Those two cases are the entire “departure” from ===. In everything else it gives the same result as ===. Full comparison of equality operators is in the equality note. Rarely needed in everyday code, but good to keep in mind as a tool.
#What to take away
Three rules that cover most everyday code:
- Compare floats with tolerance (
Math.abs(a - b) < Number.EPSILON), not with===. - Test for NaN with
Number.isNaN(), never the globalisNaN(). BigIntfor numbers larger than2^53or when integer precision matters (finance, IDs, nanosecond timestamps).
The rest - Object.is for signed zero, Number.MAX_SAFE_INTEGER as a boundary - are tools for specific situations. When the need comes, you’ll know where to reach.