29

I thought that JavaScript's loose equality operator was being nice and letting me compare Numbers with BigInts:

42 == 42n  // true!

So I tried a number bigger than Number.MAX_SAFE_INTEGER. I figured that since the number gets rounded up automatically, I might have to round up the BigInt as well for them to be considered equal:

9999999999999999 == 10000000000000000   // true - rounded to float64

9999999999999999  == 9999999999999999n  // false - makes sense!
10000000000000000 == 9999999999999999n  // false - makes sense!
9999999999999999  == 10000000000000000n // true  - makes sense!

Great, makes sense — so then I tried another big number that gets rounded up:

18446744073709551616 == 18446744073709552000   // true - rounded to float64

18446744073709551616 == 18446744073709551616n  // true?!
18446744073709552000 == 18446744073709551616n  // true?!
18446744073709551616 == 18446744073709552000n  // false?!

I observed the same results in Chrome, Safari, and Node.js.

Why isn't this behavior consistent? Is it because the numbers are compared as mathematical values, and what does that mean?

bigint equality table

jtbandes
  • 115,675
  • 35
  • 233
  • 266
  • 4
    maybe JS is just a C++ with less objects, but with the good old Undefined behavior – Alberto Sinigaglia Jun 23 '21 at 23:37
  • I don't understand the specification's description of "mathematical values", either. – Barmar Jun 24 '21 at 00:00
  • I noticed that some of my confusion is because `BigInt(18446744073709552000) === BigInt(18446744073709551616) === 18446744073709551616n`. But I still don't see why that's the case. – jtbandes Jun 24 '21 at 00:07
  • 1
    It has to have something to do how the BigInt constructor works. Check this out: `console.log(18446744073709551616) //=> 18446744073709552000` and `String(18446744073709551616) //=> "18446744073709552000"` but `BigInt(18446744073709551616) //=> 18446744073709551616n` while `BigInt(String(18446744073709551616)) //=> 18446744073709552000n` – Thomas Jun 24 '21 at 00:48
  • I guess technically the result is incorrect and is up to the implementation of "Mathematical Value" – qwr Jun 24 '21 at 02:49
  • If your question doesn't involve strings, please edit the title – qwr Jun 24 '21 at 02:55
  • 1
    Also specify which JS implementation you are using. – qwr Jun 24 '21 at 03:10
  • I included quotes in the title because the question isn't about why `18446744073709552000 == BigInt(18446744073709552000)`, but rather why `Number("18446744073709552000") != BigInt("18446744073709552000")`. – jtbandes Jun 24 '21 at 17:53

2 Answers2

22

The inconsistency is because the Number::toString abstract operation is underspecified.

Your question boils down to BigInt(String(x))BigInt(x), which might assumed to be an equality for integer numbers x but is in fact not.

In your particular case, for x=18446744073709551616 or x=18446744073709552000 (or anything in between, and even a bit around), the string representation of the number yields '18446744073709552000' whereas the exact mathematical value is 18446744073709551616. (We know this because the NumberToBigInt operation is exact - it gets you the mathematical value of the number, or an error if it's not an integer).

We also find the following note on Number.prototype.toFixed:

The output of toFixed may be more precise than toString for some values because toString only prints enough significant digits to distinguish the number from adjacent Number values. For example,

(1000000000000000128).toString() returns "1000000000000000100", while
(1000000000000000128).toFixed(0) returns "1000000000000000128".


To answer the titular question

Why does Number(“x”) == BigInt(“x”) … only sometimes?

It's because the limited precision of floating point Number values. There are multiple numeric literals that are parsed to exactly the same number. Similar to your first example, let's take the bigint 20000000000000000n. There is a floating point number with the same mathematical value, specifically

   +1 × 0b10001110000110111100100110111111000001 × 20b10001
= 1 × 152587890625 × 217
= 20000000000000000

There are multiple integer number literals that evaluate to this Number value: 19999999999999998, 19999999999999999, 20000000000000000, 20000000000000001, and 20000000000000002. (Notice it's not always rounding up). This is also what happens when you take these as strings and use unary + or Number on them.

But if you take the respective bigint literals with the same textual representation, they will evaluate to 5 different BigInt values. Only one of which will compare equal (with ==) to, i.e. have the same mathematical value as, the Number value.

This is consistent: there's always exactly one, and it's the one that can be represented precisely as a floating point number.

Why isn't this behavior consistent?

Your confusion comes from the string representation of the Number value 18446744073709551616. When printing the number literal 18446744073709551616, or also +'18446744073709551616', you got 18446744073709552000 (because the console uses String()/.toString() internally), which made you assume that 18446744073709552000 was its mathematical value, and that 18446744073709552000n should compare equal to it. But it's not :-/

console.log(18446744073709551616..toString());
console.log(18446744073709551616..toFixed());
console.log(18446744073709552000..toString());
console.log(18446744073709552000..toFixed());

Which to believe?

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • It looks like the question was changed to not involve strings. – qwr Jun 24 '21 at 02:54
  • Actually after reading the spec text of *Number::toString* again and again, I'm not certain it's underspecified. Maybe it's just mis-specified. `s × 10^(n - k) is ℝ(x)` seems awfully precise, I don't see how there could be multiple values for `s`? Maybe it's even a bug in V8? – Bergi Jun 24 '21 at 02:57
  • 1
    @Bergi same result for SpiderMonkey, isn't it the step 6 that does this weird rounding in toString? (I'm not able to read these specs fluently myself). And the toString is still involved since the most common way to see what a Number is, is to log it, which will call toString. – Kaiido Jun 24 '21 at 03:07
  • But how can `BigInt(x)` be more precise than `BigInt(String(x))`, when `x` is a float with limited precision in the first place? Wouldn't that mean that `BigInt(x)` is more precise than `x`? – Thomas Jun 24 '21 at 08:41
  • 1
    @Thomas A bigint in that range is more precise than a float, yes: `BigInt(x)+1n` is a separate value, while `x+1==x`. The problem is that `String(x)` returns a string representation of some arbitrary number in that equivalence class (it basically requires only `Number(String(x)) == x`), not the string representation of the exact floating point value. – Bergi Jun 24 '21 at 10:55
  • @Kaiido I don't see how step 6 does any rounding - doesn't `s` need to be the exact integer value? – Bergi Jun 24 '21 at 11:06
  • @Bergi as I said I can't read these specs fluently, I always found ECMA specs were written for computers more than for humans at a point that reading implementations is often clearer for me. So I can't really tell, I was under the impression that the "n - k occurrences of the code unit 0x0030" part of this step would explain pretty well why `18446744073709551616` becomes `18446744073709552000` or why`184467440737095516161` becomes `184467440737095500000` (i.e a big number with some zeroes at the end) – Kaiido Jun 24 '21 at 11:45
  • 1
    @Kaiido I've filed [issue #2444](https://github.com/tc39/ecma262/issues/2444) – Bergi Jun 24 '21 at 12:52
  • I haven't grasped from your answer how strings feature in this process. Step 13 of Abstract Equality (or `IsLooselyEqual`, in the draft) simply says to compare their mathematical values via ℝ. Does the string process apply before the `ℝ(...)` (e.g., while parsing the source?) or after (i.e., producing `ℝ(x)` requires some kind of string operation on `x`)? But if it applies before, shouldn't we examine the parsing logic (e.g., 12.8.3 Numeric Literals) rather than string operations? Or does the parsing logic also descend into string operations at some point? – apsillers Jun 24 '21 at 13:12
  • 2
    @apsillers There's no *ToString* process in the evaluation of `18446744073709552000 == 18446744073709552000n`. It just yields `false` because the mathematical value of the numeric literal `18446744073709552000` is 18446744073709551616. This is expected so far (see the comparisons with `9999999999999999 == 10000000000000000`). What is unexpected is that the Number value 18446744073709551616 is displayed as `'18446744073709552000'`, making us assume that that would be its actual mathematical value. – Bergi Jun 24 '21 at 13:17
  • 1
    @apsillers I've updated the answer to include that part. – Bergi Jun 24 '21 at 14:42
  • Good find about `toFixed()`, now there is not much place for doubts. – Kaiido Jun 24 '21 at 14:43
  • Thank you, I think the last section clarified things a lot. Had I known that `18446744073709552000..toFixed() === "18446744073709551616"`, the results would've made sense. I'm still confused by _"toString only prints enough significant digits to distinguish the number from adjacent Number values"_ — isn't that what toFixed() is doing, too? For both 1000000000000000100 and 1000000000000000128 (they are the same number), the result of `toString()` is `"…100"` and the result of `toFixed()` is `"…128"`, and they print the same number of digits—is toString faster, or why does this difference exist? – jtbandes Jun 24 '21 at 18:19
  • @jtbandes `toFixed(0)` rounds at the decimal separator, so for integers it always prints the exact mathematical value. `toString()` fills up with zeroes if the additional precision is not needed (and if there are less than 21 digits, otherwise it switches to scientific notation). – Bergi Jun 24 '21 at 23:00
2

My guess is that this is due to the exact implementation of mathematical value not being specified (or at least I couldn't find it).

The abstract equality comparison is specified https://262.ecma-international.org/11.0/#sec-abstract-equality-comparison

If Type(x) is BigInt and Type(y) is Number, or if Type(x) is Number and Type(y) is BigInt, then

If x or y are any of NaN, +∞, or -∞, return false.

If the mathematical value of x is equal to the mathematical value of y, return true; otherwise return false.

So a BigInt should have a mathematical value of some integer, and a Number should have a mathematical value of some kind of scientific notation number, and so the comparisons should be intuitive, but exactly how your JS engine implements mathematical value is not following the spec in spirit.

qwr
  • 9,525
  • 5
  • 58
  • 102
  • 2
    I think the mathematical value is the one that [a number value represents](https://tc39.es/ecma262/#sec-ecmascript-language-types-number-type) (in the form of `s × m × 2^e`, not "*some kind of scientific notation*") or that [a bigint value represents](https://tc39.es/ecma262/#sec-ecmascript-language-types-bigint-type). – Bergi Jun 24 '21 at 11:10