DEV Community

loading...
Cover image for Understanding Loose Equality In JavaScript

Understanding Loose Equality In JavaScript

emnudge profile image EmNudge ・7 min read

For those who prefer a more audiovisual form, a video almost identical to the article can be seen over here:

Abstract Equality, or as I've titled this article "Loose Equality" is (I think) one of the most misunderstood topics in JavaScript. People know loose equality, the double equals (==), to check if its operands are roughly equal to each other. The string "55" and the number 55 are kind of the same thing, but not strictly the same thing, with triple equals (===).

People usually advise against using loose equality. Personally? Well if JavaScript came out with a **strict* strict mode* that removed loose equality, I wouldn't be too bothered.

But there's a lot of misinformation out there and I thought it would be helpful to clean some of that up. Which is why I've been working on this topic for so long.

// loose equality vs strict equality
"55" == 55   // -> true
"55" === 55  // -> false
Enter fullscreen mode Exit fullscreen mode

Loose equality, in reality, is a process that tries to implicitly coerce its operands to be the same type before passing it off to strict equal to give you the real result. Implicit coercion by itself actually isn't too bad. It's used in many other languages and JavaScript programmers use it pretty often.

In this example, we take advantage of falsy and truthy values to check whether we should print out an array to the console. If the array exists and has a length property greater than 0, print it out.

// example of implicit coercion
const myArr = [1, 2, 3, 4, 5];
if (myArr && myArr.length) {
  console.log("My arr is: " + myArr);
}
Enter fullscreen mode Exit fullscreen mode

Falsy values include all the JavaScript values that will evaluate to false when converted into a boolean.

Boolean('')         // -> false
Boolean(0)          // -> false
Boolean(0n)         // -> false
Boolean(NaN)        // -> false
Boolean(null)       // -> false
Boolean(undefined)  // -> false
Enter fullscreen mode Exit fullscreen mode

Do not confuse this with abstract equality, however. Double equals often does not rely on this system whatsoever. While using the exact same values, we get true for half only. I'm no statistician, but 50-50 looks like zero correlation to me.

false == ''         // -> true
false == 0          // -> true
false == 0n         // -> true
false == NaN        // -> false
false == null       // -> false
false == undefined  // -> false
Enter fullscreen mode Exit fullscreen mode

In fact, I would go so far as to say the concept of falsy values never comes up within abstract equality in the spec? What's the spec?

The JavaScript specification is an esoteric document that instructs browsers on how JavaScript should work. Browsers all can code up the implementation themselves, but if you want to know how JavaScript works without digging through C++ code, this is the best place to look.

The spec can often be pretty confusing, but this particular section is actually kind of readable. It defines abstract equality as a list of steps and I think it's pretty cool. If you're ever wondering why null is loosely equal to undefined, this is why. Because it says so. There is no low-level reason why it must be that way - the discussion stops here. It works that way because the document says it should.

While I can go through the document, I'm going to instead use a tool I've been working on to explain it a bit more simply - The Abstract Equality Stepper. I've written up the steps to roughly match spec. There are some minor changes in formatting to help with how my tool works, but it's essentially the same.

preview of tool

Let's punch in some examples we've just shown to explore how this works. false and 0 perhaps.

Untitled (1)

(View it here)

We can see that it declares either of the operands are a boolean, we convert the boolean to a number. Always. No matter what the other value is.

Notice that it tells us to perform an abstract equality comparison, but these are the steps that define what an abstract equality comparison is. That's right, this is recursion. We restart with new values. Since the types are now equal, we chuck it off to a strict equality comparison which returns true since they're the same value.

Notice that abstract equality uses strict equality.
Untitled (2)

So technically abstract equality must be less performant if the implementation matches the spec exactly. This is way too minor to matter in practice, but I thought it was interesting.

Let's try false and ''. We convert the boolean to a number like last time, but now we're left with a number versus a string.

Untitled (3)

(View it here)

We convert the string to a number and then go to strict equality. We're converting to numbers a lot here. It's for good reason. Numbers can be thought of as the most primitive type. It's easy to compare number to number and it's essentially what we're doing when we compare anything else. Even when we compare using reference equality (like with 2 objects) we're comparing memory locations, which, as you might have guessed, are numbers.

We can substitute 0 for false for all of the other examples.

0 == NaN        // -> false
0 == null       // -> false
0 == undefined  // -> false
Enter fullscreen mode Exit fullscreen mode

0 isn't NaN so that's gonna be false. And then there is no step to define 0 and null or undefined, so we get false by default.

Untitled (4)

Nothing to do with falsy values here. Just looking at steps and following the rules.

With that out of the way, let's look at a common example of abstract equality weirdness - a real headscratcher.

WTFJS - The Headscratcher

![] == []  // -> true
Enter fullscreen mode Exit fullscreen mode

This looks paradoxical, but it actually makes sense. First, we convert the left array to a boolean. This does involve the concept of falsy, but we haven't touched abstract equality yet, just expression evaluation. Since arrays aren't falsy, we would get true, but we're using an exclamation mark, so we flip that and get false.

false == []
Enter fullscreen mode Exit fullscreen mode

Since booleans always turn to numbers in this system, our operands are 0 and []. Now what?

Well, now we find ourselves face to face with the magical ToPrimitive. This one is interesting. We can't just compare a primitive value and an object, we need 2 primitive values or 2 objects. We try turning our array into a primitive and out pops an empty string.

(Note: a function is just a callable object. When we use the term object, we include functions)

0 and '' means we turn the string into a number, which leads us to 0 and 0 which are equal.

But how does ToPrimitive work? What does it do?

We can look at the spec again, but this time it's a little more difficult, so I've taken the liberty of converting it to plain JavaScript.

If we're passed a primitive value, just return that. No need to convert a primitive to a primitive.

Then we check for a Symbol.toPrimitive property. This is a rather recent addition to JavaScript which allows you to define the ToPrimitive behavior a bit more easily.

Untitled (4)

If such a method exists, we try to convert it to a number. How? We check for a .valueOf property, which is what Number calls. If you try adding your object to a number, it will try to look for this property and call it.

If this property doesn't exist on your object or it itself returns an object, we try converting it to a string. Using, of course, the .toString property. This is actually defined on all object by default, including arrays. If you don't touch your object then ToPrimitive will return a string. For arrays, this means returning all its values as a comma-separated list. If it's empty, that's an empty string.

const obj = {
    valueOf() {
        console.log('calling valueOf');
        return 100;
    },
    toString() {
        console.log('calling toString');
        return '👀';
    }
};

console.log(obj + 43);

console.log(`I see you ${obj}`);
Enter fullscreen mode Exit fullscreen mode

(Note: string concatenation itself doesn't always call .toString)

And there's your explanation!

But if you look a bit closer, you'll notice a few errors being thrown. Wait, does that mean...

Yup! There are often times where just using double equals will throw an error instead of returning false. Let's create such a scenario right now.

Throwing Errors With Equality Checks

const obj1 = {
    [Symbol.toPrimitive]: 45
};

console.log(obj1 == 45);
// Uncaught TypeError: number 45 is not a function
Enter fullscreen mode Exit fullscreen mode

We can also just make it a function, but return an object.

const obj2 = {
    [Symbol.toPrimitive]: () => Object()
};

console.log(obj2 == 45);
// Uncaught TypeError: Cannot convert object to primitive value
Enter fullscreen mode Exit fullscreen mode

Or do the same with the other methods

const obj3 = {
    toString: () => Object(),
    valueOf: () => Object()
};

console.log(obj3 == 45);
// Uncaught TypeError: Cannot convert object to primitive value
Enter fullscreen mode Exit fullscreen mode

Now, we can't actually delete these methods on most objects. I mentioned earlier that all objects implement this by default. All objects of course inherit this method from the object prototype and we can't really delete that.

However, it's possible to make an object with no prototype using Object.create(null). Since it has no prototype, it has no valueOf() and no toString() and thus it will throw an error if we compare it to a primitive. Magical!

Object.create(null) == 45
// Uncaught TypeError: Cannot convert object to primitive value
Enter fullscreen mode Exit fullscreen mode

With that detour, let's close with the essence of this article - how to understand loose equality.

Conclusion

When comparing 2 things of different types, it'll help to convert the more complex type to a simpler representation. If we can convert to a number, do that. If we're adding an object to the mix, get the primitive value and again try squeezing a number out of it.

null and undefined are loosely equal and that's that.

If we get something like Symbol() or we compare null or undefined with anything else by each other, we get false by default. Symbol() actually has a .toString() method, but it doesn't really matter. The spec says we get false, so we get false.

If we want to describe the steps in a bit of a simpler form, it looks something like this:

  1. null equals undefined
  2. Number(string) == number
  3. BigInt(string) == bigint
  4. Number(boolean) == anything
  5. ToPrimitive(object) == anything
  6. BigInt(number) == bigint
  7. false

Stay curious!

Discussion (4)

pic
Editor guide
Collapse
ashvin777 profile image
Ashvin Kumar Suthar

A suggestion, if you want to see jsitor snipepts embedded here, you can use jsitor liquid tag. Its already supported. Check the dev.to liquid tag section for details.

Collapse
emnudge profile image
EmNudge Author

I didn't, but that sounds like a good idea, so I've gone ahead and converted it to a liquid tag!

While I have you here, however, are there any plans to add a tsConfig.json file to jsitor? I had to abandon using TS for a couple of REPLs because it wouldn't allow me to use modern JS syntax without a bunch of
// @ts-ignore
being thrown around the place.

Collapse
ashvin777 profile image
Ashvin Kumar Suthar

We didn't have any plan yet to add a config file, however, we can allow modern JS syntax if anything is missing and make it configurable if required.

Is there any particular syntax with which you are looking for that is not working? Please let us know.
You can also report any bugs or requests on our Github issues page - github.com/jsitor/jsitor/issues/ne...

Thread Thread
emnudge profile image
EmNudge Author

.includes() and BigInt() are the ones that have been specifically annoying me when making recent snippets, but I'm sure there are more that I just haven't come across yet.
Using symbols as index values might just not be allowed in TS at all, so idk if there's a good solution to that.
i.e. obj[Symbol.toPrimitive] requires any as an index or just // @ts-ignore