Understanding Loose Equality In JavaScript
EmNudge
Posted on January 6, 2021
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
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);
}
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
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
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.
Let's punch in some examples we've just shown to explore how this works. false
and 0
perhaps.
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.
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.
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
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.
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
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 == []
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.
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}`);
(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
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
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
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
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:
- null equals undefined
- Number(string) == number
- BigInt(string) == bigint
- Number(boolean) == anything
- ToPrimitive(object) == anything
- BigInt(number) == bigint
- false
Stay curious!
Posted on January 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.