JAVASCRIPT / NUMBERS, MATH, AND BIGINT
Safe integers and Number limits
Work out which integers JavaScript can represent exactly, check them with Number.isSafeInteger, and recognise where sums stall or overflow to Infinity.
What you will learn
- Read Number.MAX_SAFE_INTEGER as 2**53 - 1 and know why the limit sits there
- Validate incoming integers with Number.isSafeInteger, not Number.isInteger
- Separate the precision limit (9007199254740991) from the magnitude limit (~1.8e308)
- Detect silent failures: stalled sums, ids that compare equal, overflow to Infinity
Understanding Safe integers and Number limits
Every JavaScript number is an IEEE 754 double: a sign, an 11-bit exponent, and a significand carrying 53 bits of precision. Those 53 bits are exactly enough to give every integer from -(2**53 - 1) to 2**53 - 1 its own distinct bit pattern. Once the magnitude reaches 2**53 the exponent has to step up, so the gap between neighbouring representable values becomes 2, then 4 above 2**54, doubling at every further power of two. An integer is therefore "safe" not just when it is stored exactly, but when no other integer collapses onto the same double, which is why 2**53 itself is unsafe despite being stored exactly: 2**53 + 1 rounds straight onto it.
Number.MAX_SAFE_INTEGER (9007199254740991) and Number.MIN_SAFE_INTEGER mark the ends of that range, and Number.isSafeInteger(x) is true only when x is a number, is integral, and falls inside it. It performs no coercion, so the string '42' is false while 42.0 is true. Keep this separate from Number.MAX_VALUE, about 1.8e308, which is merely the largest finite double and says nothing about integer accuracy: between roughly 9e15 and 1.8e308 there are countless finite, integral numbers whose nearest neighbours are millions or 10**290 apart.
Nothing throws when you cross MAX_SAFE_INTEGER. Arithmetic simply returns the nearest representable double, so repeated additions of 1 stop changing the value, two different integers begin comparing equal, and a loop waiting for a counter to pass a target can spin forever. Cross Number.MAX_VALUE and the result overflows to Infinity, again silently; fall below Number.MIN_VALUE (about 5e-324) and it underflows to 0. The mental model worth keeping is that JavaScript's number line is not evenly spaced: resolution thins out as magnitude grows, and the safe integer range is the stretch where integer arithmetic is still exact.
const max = Number.MAX_SAFE_INTEGER;
console.log(max);
console.log(max + 1);
console.log(max + 2);
console.log(max + 1 === max + 2);
console.log(Number.isSafeInteger(max), Number.isSafeInteger(max + 1));
console.log(Number.MAX_VALUE);
console.log(Number.MAX_VALUE + 1 === Number.MAX_VALUE);
console.log(Number.MAX_VALUE * 2);A double carries only 53 bits of significand, so past 2**53 - 1 consecutive integers stop having their own representation and integer arithmetic rounds without warning.
Worked examples
isInteger is not isSafeInteger
Shows that being an integer and being a trustworthy integer are different questions.
console.log(Number.isInteger(2 ** 53), Number.isSafeInteger(2 ** 53));
console.log(Number.isInteger(1e21), Number.isSafeInteger(1e21));
console.log(Number.isSafeInteger(42.0), Number.isSafeInteger(42.5));
console.log(Number.isSafeInteger('42'), Number.isSafeInteger(NaN));Example explained
Line 12 ** 53 has no fractional part, so isInteger says true, but it shares its double with 2 ** 53 + 1, so isSafeInteger says false.
Line 21e21 is stored exactly and is integral, yet its neighbours are 131072 apart, so it fails the safety test.
Line 342.0 passes because JavaScript has no separate integer type: the literal is the same double as 42.
Line 4'42' and NaN both return false, because isSafeInteger never coerces its argument and NaN is not integral.
A JSON id that loses its last digit
Demonstrates a large identifier being rounded during JSON.parse before your code ever runs.
const body = '{"a": 9007199254740993, "b": 9007199254740992}';
const ids = JSON.parse(body);
console.log(ids.a);
console.log(ids.a === ids.b);
console.log(Number.isSafeInteger(ids.a));Example explained
Line 1The JSON grammar allows arbitrarily many digits, but JSON.parse must produce a Number, so 9007199254740993 rounds to the nearest double, 9007199254740992.
Line 2ids.a === ids.b is true although the payload held two different integers, so any lookup or deduplication keyed on these ids merges two records.
Line 3Number.isSafeInteger(ids.a) returning false is the only signal available; no parse error is raised.
Line 4Transmitting such ids as JSON strings avoids the problem, because no double is ever created from the digits.
Guarding a running total
Checks the result of an addition rather than trusting that safe inputs give a safe answer.
function addCount(total, delta) {
const next = total + delta;
if (!Number.isSafeInteger(next)) {
throw new RangeError('count left the safe integer range');
}
return next;
}
console.log(addCount(10, 5));
try {
addCount(Number.MAX_SAFE_INTEGER - 1, 5);
} catch (err) {
console.log(err.name + ': ' + err.message);
}Example explained
Line 1next is computed first and then tested; checking only total and delta would miss the case where both operands are safe but their sum is not.
Line 2addCount(10, 5) returns 15 because operands and result all sit far inside the range, so the addition is exact.
Line 39007199254740990 + 5 lands above the limit and is rounded, so the guard throws instead of returning a total that is quietly off by one.
Line 4When two safe integers add to a safe integer the result is always exact, which is what makes this single check sufficient for + and -.
Important notes
Bitwise operators impose a much smaller, separate limit: operands are truncated to 32-bit signed integers, so 2 ** 31 | 0 is -2147483648 even though 2 ** 31 is a perfectly safe integer.
Number.MIN_VALUE is not the most negative number; it is the smallest positive value, about 5e-324. The most negative finite number is -Number.MAX_VALUE.
Common mistakes
Using Number.isInteger as the safety check: 2 ** 53 and 1e21 both pass it, so a rounded id sails through validation and two different records end up comparing equal.
Expecting an error past the limit: max + 1 + 1 just returns the same number, so a counter silently stops incrementing and a while (i < target) loop never terminates.
Treating Number.MAX_VALUE as the integer limit: 1e20 is finite and far below MAX_VALUE yet cannot distinguish 100000000000000000001 from 100000000000000000000, so a range check against MAX_VALUE proves nothing about integer accuracy.
Try it yourself
Change, predict, then run
In a browser console, start from Number.MAX_SAFE_INTEGER - 3 and add 1 six times, logging the value and Number.isSafeInteger(value) each round. Identify the exact value where the sequence stops changing and explain why that value, not the one before it, is the first unsafe one.
Open the JavaScript workspaceCheck your understanding
Why is 2 ** 53 not a safe integer, even though JavaScript stores it exactly?
- Because 2 ** 53 + 1 rounds to the same double, so the value no longer identifies a single integer
- Because 2 ** 53 is larger than Number.MAX_VALUE and therefore overflows
- Because 2 ** 53 needs 54 bits and loses its lowest bit when stored
- Because Number.isSafeInteger rejects any value produced by the ** operator
Show answer
Safety requires a one-to-one mapping between the integer and its double. 2 ** 53 is stored perfectly, but 2 ** 53 + 1 has nowhere else to go and rounds onto it, so a value of 9007199254740992 is ambiguous. Option three is tempting because "unsafe" sounds like "inexact", yet 2 ** 53 is 1 followed by zeros in binary and fits the 53-bit significand with room to spare; the problem is its neighbour above, not itself. Option two is off by 292 orders of magnitude.