Large numbers erroneously rounded in JavaScript

38,667

Solution 1

What you're seeing here is actually the effect of two roundings. Numbers in ECMAScript are internally represented double-precision floating-point. When id is set to 714341252076979033 (0x9e9d9958274c359 in hex), it actually is assigned the nearest representable double-precision value, which is 714341252076979072 (0x9e9d9958274c380). When you print out the value, it is being rounded to 15 significant decimal digits, which gives 14341252076979100.

Solution 2

You're overflowing the capacity of JavaScript's number type, see §8.5 of the spec for details. Those IDs will need to be strings.

IEEE-754 double-precision floating point (the kind of number JavaScript uses) can't precisely represent all numbers (of course). Famously, 0.1 + 0.2 == 0.3 is false. That can affect whole numbers just like it affects fractional numbers; it starts once you get above 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER).

Beyond Number.MAX_SAFE_INTEGER + 1 (9007199254740992), the IEEE-754 floating-point format can no longer represent every consecutive integer. 9007199254740991 + 1 is 9007199254740992, but 9007199254740992 + 1 is also 9007199254740992 because 9007199254740993 cannot be represented in the format. The next that can be is 9007199254740994. Then 9007199254740995 can't be, but 9007199254740996 can.

The reason is we've run out of bits, so we no longer have a 1s bit; the lowest-order bit now represents multiples of 2. Eventually, if we keep going, we lose that bit and only work in multiples of 4. And so on.

Your values are well above that threshold, and so they get rounded to the nearest representable value.

As of ES2020, you can use BigInt for integers that are arbitrarily large, but there is no JSON representation for them. You could use strings and a reviver function:

const jsonString = '{"id":"714341252076979033","type":"FUZZY"}';
// Note it's a string −−−−^−−−−−−−−−−−−−−−−−−^

const obj = JSON.parse(jsonString, (key, value) => {
    if (key === "id" && typeof value === "string" && value.match(/^\d+$/)) {
        return BigInt(value);
    }
    return value;
});

console.log(obj);
(Look in the real console, the snippets console doesn't understand BigInt.)

If you're curious about the bits, here's what happens: An IEEE-754 binary double-precision floating-point number has a sign bit, 11 bits of exponent (which defines the overall scale of the number, as a power of 2 [because this is a binary format]), and 52 bits of significand (but the format is so clever it gets 53 bits of precision out of those 52 bits). How the exponent is used is complicated (described here), but in very vague terms, if we add one to the exponent, the value of the significand is doubled, since the exponent is used for powers of 2 (again, caveat there, it's not direct, there's cleverness in there).

So let's look at the value 9007199254740991 (aka, Number.MAX_SAFE_INTEGER):

   +−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− sign bit
  / +−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− exponent
 / /        |  +−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−+− significand
/ /         | /                                                  |
0 10000110011 1111111111111111111111111111111111111111111111111111
                = 9007199254740991 (Number.MAX_SAFE_INTEGER)

That exponent value, 10000110011, means that every time we add one to the significand, the number represented goes up by 1 (the whole number 1, we lost the ability to represent fractional numbers much earlier).

But now that significand is full. To go past that number, we have to increase the exponent, which means that if we add one to the significand, the value of the number represented goes up by 2, not 1 (because the exponent is applied to 2, the base of this binary floating point number):

   +−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− sign bit
  / +−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− exponent
 / /        |  +−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−+− significand
/ /         | /                                                  |
0 10000110100 0000000000000000000000000000000000000000000000000000
                = 9007199254740992 (Number.MAX_SAFE_INTEGER + 1)

Well, that's okay, because 9007199254740991 + 1 is 9007199254740992 anyway. But! We can't represent 9007199254740993. We've run out of bits. If we add just 1 to the significand, it adds 2 to the value:

   +−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− sign bit
  / +−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− exponent
 / /        |  +−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−+− significand
/ /         | /                                                  |
0 10000110100 0000000000000000000000000000000000000000000000000001
                = 9007199254740994 (Number.MAX_SAFE_INTEGER + 3)

The format just cannot represent odd numbers anymore as we increase the value, the exponent is too big.

Eventually, we run out of significand bits again and have to increase the exponent, so we end up only being able to represent multiples of 4. Then multiples of 8. Then multiples of 16. And so on.

Solution 3

It is not caused by this json parser. Just try to enter 714341252076979033 to fbug's console. You'll see the same 714341252076979100.

See this blog post for details: http://www.exploringbinary.com/print-precision-of-floating-point-integers-varies-too

Solution 4

JavaScript uses double precision floating point values, ie a total precision of 53 bits, but you need

ceil(lb 714341252076979033) = 60

bits to exactly represent the value.

The nearest exactly representable number is 714341252076979072 (write the original number in binary, replace the last 7 digits with 0 and round up because the highest replaced digit was 1).

You'll get 714341252076979100 instead of this number because ToString() as described by ECMA-262, §9.8.1 works with powers of ten and in 53 bit precision all these numbers are equal.

Solution 5

The problem is that your number requires a greater precision than JavaScript has.

Can you send the number as a string? Separated in two parts?

Share:
38,667
Jaanus
Author by

Jaanus

Design-driven engineer. iOS. Mac. Web frontend and some backend. Pixel pushing. Gestures, animations, transitions.

Updated on July 05, 2022

Comments

  • Jaanus
    Jaanus almost 2 years

    See this code:

    var jsonString = '{"id":714341252076979033,"type":"FUZZY"}';
    var jsonParsed = JSON.parse(jsonString);
    console.log(jsonString, jsonParsed);

    When I see my console in Firefox 3.5, the value of jsonParsed is the number rounded:

    Object id=714341252076979100 type=FUZZY
    

    Tried different values, the same outcome (number rounded).

    I also don't get its rounding rules. 714341252076979136 is rounded to 714341252076979200, whereas 714341252076979135 is rounded to 714341252076979100.

    Why is this happening?

  • Rick Regan
    Rick Regan over 14 years
    Thanks for linking to my article, but it only explains half the problem -- the PRINTING of the internally rounded value. Even if javascript let you print the whole thing, it would still be wrong -- it would be the nearest representable double-precision value, as explained by others below.
  • jsh
    jsh about 11 years
    I like this answer because it actually tells you how to SOLVE the problem.
  • Monish Chhadwa
    Monish Chhadwa almost 5 years
    How are the 15 significant decimal digits "143412520769791" instead of "714341252076979" is what I didn't understand
  • Special Character
    Special Character over 3 years
    This is a fantastic answer and was exactly what I was looking for.
  • user3125367
    user3125367 about 3 years
    This answer seems to have two mistakes: 1) minor, leading 7 is missing from the last number, 2) major, the output is not rounded to 15 digits -- it is also a nearest representation of a 53-bit mantissa float, which takes approx 15.95 decimal digits. That ...100 part is not as stable as rounding, e.g. ...79135 errs into ...79100 and ...79136 errs into ...79200, and even this ...35/...36 limit will drift arbitrarily. (Pedantic mode: in a sense, it is rounding, because it "rounds" to 15.95 decimal places)
  • Sebastian Simon
    Sebastian Simon over 2 years