18
JavaScript weird type system - Double equal sucks
Wassup mate, hate to say it but it's finally last part of this series, and probably that's the most important part of them all, because we're going to speak about equality finally! so let's get started.
I'll start off by arguing against the most well known quote in programming history -probably-
The double equal operator checks for value only while triple equal operator checks for value and type
I am quite sure you've seen this statement somewhere, even I said it back in the time in some of my courses, but hopefully in this article I can rephrase this wrong understanding, or atleast remove the bluriness off of it. So, let's get into it by first using these operators' real identifiers.
Actually this operator was renamed in the TC39 specs to isLooselyEqual which is -tbh- much more semantic name than abstract, as being loose means more flexibility while comparing types, but what happens during this operation?
The double equal operation can be thought of as a function that goes through some steps or checks, the very first one of them is checking if the types of the arguments are same, guess what happens then, yeah it calls the tripe equal function.
So double equal can become triple equal under some conditions, they're not so different after all, it's all about the behaviour that each one of them deals with the types of the inputs.
Let's make some psuedocode about these two operations, but surely we won't be able to cover all of the cases because some cases need access to memory addreses to compare objects, arrays, functions so we will just have some mockery code.
function doubleEqual(x, y) {
// Handling the first case that types may be the same
if (typeof x === typeof y) {
return tripleEqual(x, y)
}
}
function tripleEqual(x, y) {
/* Some implementation */
}
Well what if the types are not equal?
The operation goes through the checks from 2 to 9 and if none of these conditions were met, it returns false as the default return.
It's quite obvious in case 2 and case 3 if these values -x and y- are undefined or null interchangeably, the operation returns true, as it treats them as if they were non-existing values so it coerces them to the same type for easier comparison.
function doubleEqual(x, y) {
// previous checks
// handing case 2 and 3
// Note that you can separate them into two if statements
if (
((typeof x === 'undefined' ||
typeof y === 'undefined') &&
typeof x === 'object' &&
!x) ||
(typeof y === 'object' && !y)
) {
return true
}
return false // default return
}
This long condition can be represented using the following table
typeof x | typeof y | Result |
---|---|---|
undefined | undefined | true |
undefined | null | true |
null | undefined | true |
null | null | true |
We're simply accepting if both of them are any sort of a combination between the undefined and null types, any other type will not be handled by this case.
Once again, case 4 and 5 handle the cases if they're either string or number so it coerces one to the other.
In case 4, we're checking if x is number and y is a string, if so, we return the result of comparing them but coercing y to number
And in case 5, we're checking if x is string and y is a number, if so, we return the result of comparing them but coercing x to number
function doubleEqual(x, y) {
// previous checks
// handling case 4
if (typeof x === 'number' && typeof y === 'string') {
return x == Number(y)
}
// handling case 5
if (typeof x === 'string' && typeof y === 'number') {
return Number(x) == y
}
}
Once again, let's simplify these two cases in a table form
typeof x | typeof y | Result |
---|---|---|
number | string | x == Number(y) |
string | number | Number(x) == y |
In case 6 and 7 we're handling if one of them is a boolean
value, but note that booleans are only comparable to numbers, the JSE (JavaScript Engine) is only allowed to coerce the boolean
to a number
.
Case 6 checks if x
is the boolean
type, if so it converts it to number
first and then compare it to y
function doubleEqual(x, y) {
// previous checks
// handling case 6
if (typeof x === 'boolean') {
return Number(x) == y
}
// handling case 7
if (typeof y === 'boolean') {
return x == Number(y)
}
}
The final two cases, 8 and 9 check if one of them is an object
and the other is either string
, number
, or symbol
, if so, the one has the type object
is compared after calling the [Symbol.toPrimtive]
method from.
In case 8, if x
is either string
, number
, symbol
and y
is object, we compare x
as it is with the returned value from calling ToPrimitive
on y
In case 9, if y
is either string
, number
, symbol
and x
is object, we compare y
as it is with the returned value from calling ToPrimitive
on x
function doubleEqual(x, y) {
// previous checks
// handling case 8
if (
(typeof x === 'string' ||
typeof x === 'number' ||
typeof x === 'symbol') &&
typeof y === 'object' &&
y !== null
) {
return x === y[Symbol.toPrimitive](typeof x)
}
// handling case 9
if (
(typeof y === 'string' ||
typeof y === 'number' ||
typeof y === 'symbol') &&
typeof x === 'object' &&
x !== null
) {
return x[Symbol.toPrimitive](typeof y) === y
}
}
It may have been not obvious, but those two cases require more checks that it's stated, we had to check that none of them is the null
object, because as we've discussed in earlier article that null
is an object in js.
And we had to invoke the ToPrimitive
operation ourselves because we wanted to pass the hint
to it as the typeof x
assuming that this object knows to handle these types, if that sounds new to you, check my previous article where we talked about type coercion.
So putting all together, we would end up having a huge function that looks like the following.
function doubleEqual(x, y) {
if (typeof x === typeof y) {
return tripleEqual(x, y)
}
if (
((typeof x === 'undefined' ||
typeof y === 'undefined') &&
typeof x === 'object' &&
!x) ||
(typeof y === 'object' && !y)
) {
return true
}
if (typeof x === 'number' && typeof y === 'string') {
return x == Number(y)
}
if (typeof x === 'string' && typeof y === 'number') {
return Number(x) == y
}
if (typeof x === 'boolean') {
return Number(x) == y
}
if (typeof y === 'boolean') {
return x == Number(y)
}
if (
(typeof x === 'string' ||
typeof x === 'number' ||
typeof x === 'symbol') &&
typeof y === 'object' &&
y !== null
) {
return x === y[Symbol.toPrimitive](typeof x)
}
if (
(typeof y === 'string' ||
typeof y === 'number' ||
typeof y === 'symbol') &&
typeof x === 'object' &&
x !== null
) {
return x[Symbol.toPrimitive](typeof y) === y
}
return false
}
Probably it's irritating for you that we're using the equal operator itself within this algorithm, but hey, how would you do other way? PS: Lemme know if you know some other way o.o
But phew, we've made it, we've mocked the double equal operation, it's hella long one and probably has some mistakes within it, but hope fully you got the sense of it.
Double equal is all about handling types and coercing them to fit - Me if none said it before
But cheer up, this isn't goodbye yet, we still have to talk about triple equal and some JS wtfs :D
This operator is also renamed in the TC39 specs to isStrictlyEqual which is again a better name IMO, it indicates that this operation is delegated to a function or series of functions, which is actually what happens.
The same cycle repeats itself, the triple equal performs some checks against the two given values and reacts according to them.
We've seen this case before, you may think why they're repeated themselves, well they're not, the double equal delegates the work to the triple equal in case the given inputs have the same types, but what about the case when you use the triple equal directly? this case has to be handled aswell. thus, they have this first case of checking against same types equality.
function tripleEqual(x, y) {
if (typeof x !== typeof y) {
return false
}
}
If the type of x
is number
then both of them are numbers logically, because if we're at the state of having this check, they must have passed the first check where the two inputs have to be of same type, so that's one check less.
function tripleEqual(x, y) {
// previous checks
if (typeof x === 'number') {
return equalNumbers(x, y) // some outsourcing function
}
}
function equalNumbers(x, y) {
return !(x < y || x > y) // being fancy not using !== :D
}
Probably the same case as before, but it checks against bigint
type because numbers and big integers aren't the same, yeah they're numbers after all, but in terms of representations they're different.
function tripleEqual(x, y) {
// previous checks
if (typeof x === 'bigint') {
return equalBigIntNumbers(x, y) // some outsourcing function
}
}
function equalBigIntNumbers(x, y) {
return !(x < y || x > y) // Dunno other way of checking against bigints tbh
}
This one actually is the one we cannot have a code representation of, because it uses memory addresses and some things we can't access using native JS, but we'll have another hack representing that our memory is just an object
This case returns the value of comparing two non numeric values if they're the same or not, which denotes that the triple equal is mostly about comparing numbers, the three previous cases were against types and one was about immediate return if types mismatch.
And I find this behaviour kinda reasonable, we have the double equal to handle different types and make them suitable for each other, and we also have the double equal invoking the triple equal if the types are exact, so why not delegate numbers handling to triple equal? because later on we will find out that the double equal always yields to a triple equal operation.
But for now, let's walk through this SameValueNonNumeric
operation and see how it behaves.
This operation starts off by making an assertion that those types are exact, which makes it reasonable to invoke this operation from the triple equal operation not the double equal.
Afterwards it goes through a list of checkings about these inputs.
In this step, the algorithm checks if is x
is undefined, if so, it returns true, because that means that both of them are undefined
based on the assertion made earlier.
function sameValueNonNumeric<T>(x: T, y: T): boolean {
if (typeof x !== typeof y) {
throw new Error('Type mismatch error') // Semi assertion
}
if (typeof x === 'undefined') {
return true
}
}
This step is more like the previous one, but checks if they're nulls
function sameValueNonNumeric<T>(x: T, y: T): boolean {
// previous checks
// we have to check that its the null object using !x
if (typeof x === 'object' && !x) {
return true
}
}
If x
is string, then the comparison is done according to their unicode representation
function sameValueNonNumeric<T>(x: T, y: T): boolean {
// previous checks
if (typeof x === 'string') {
return compareUsingUnicode(x, y) // some imaginary function does the comparison
}
}
If x
is boolean, a.k.a both of them are booleans, we must check that both are whether true or false
function sameValueNonNumeric<T>(x: T, y: T): boolean {
// previous checks
if (typeof x == 'boolean') {
return (x && y) || (!x && !y)
// either both are true booleans or both are false booleans
}
}
Those steps check if they're of the type symbol
and objects, but I'll skip the implementation because it may become more complex as we have to make memory-like hash map and something represent the memory reference/id, but hopefully you get the idea behind it, it's compared using their memory id
Phew, I'm really glad if you've made it to here, here's some motivation for you to keep going.
So, after all of this, what's next? Well, hopefully you knew what's the major difference between double equal and tripe equal, they both check types, but each one makes a different use of them, so let me put a definition of how I understand the difference.
Double equal and tripe equal operations are exact if types are met, otherwise, the double equal makes use of type coercion while triple equal doesn't.
Let's have some examples to solidify what you learnt about these differences, but before we do so, let's revisit something I mentioned, that the double equal operation makes use of triple equal eventually.
I think you've noticed that through the coding examples we've made, that whenever the types are the same, the double equal operation invokes the triple equal operation, but that's not what I meant.
Actually, the double equal operation is a recursive operation not sequential through some conditions.
It's true that we have these checks, but whenever a condition is met, we recursively invoke this doubleEqual
function to yield back to the tripleEqual
after coercing both types to an intermediate type which is equal in both cases according to the conditions we've seen.
So, with that in mind, let's walk through some code examples, or to put it more precisely, some JS WATs or WTFs, whichever you wanna call them.
console.log([] == ![]) // true
Probably this is one of the most famous wtfs in JS, how come an array is not equal to itself, but probably you can make sense out of it, take a couple of mins thinking of it.
We're back after this little pause, hopefully you've figured it out, but if not, the answer is very simple, type coercion came in place
Sure that's the short answer, so let's have a deeper look.
For the sake of clarification, let's name these arrays posts
and tweets
as in they're response from some API
let posts = []
let tweets = []
if (posts == !tweets) {
// this code will run
}
Alright, that's the same logic as before, what next?
Well let's have a deeper look at the operations in order, first the posts
array is untouched, and the double equal operation is nothing new, but this negate operator !
around the array.
This operator is a type coercion operation, because you can't negate strings, or any non-boolean type, right?
So it has to cast this type first to boolean a.k.a calling Boolean(tweets)
and we've discussed this before in a previous article, the ToBoolean
operation is a look up operation against the falsy values, which doesn't include arrays, so the ToBoolean
operation over an array even it's empty will return true
and the negation will make it false
So once again, the operation is recursively invoked with the new values/types, as if we're doing now doubleEqual([], false)
So now this operation is invoked with two different types, so the first condition of invoking triple equal is delayed.
I don't know if I said or not, but it's easier for the language to compare primitives, thus it converts the non-primitive type to a primitive one, a.k.a coercing the array to a string because it's easier.
So the comparison ends up being something like the following.
let posts = []
let tweets = []
if ('' == false) {
// this code will run
}
Once again, they are different types, so we have to coerce them to an intermediate type so we can invoke the triple equal operation.
So, which one would be suitable to coerce a string and a boolean to? YES, number
Meh, only if ""
becomes NaN
So yea, they both become zeros as discussed before. And the comparison becomes something like the following.
let posts = []
let tweets = []
if (0 == 0) {
// this code will run
}
And finally, they're both of the type number
so the triple equal operation is invoked given these two inputs which returns true ofc.
console.log([] != [])
Hopefully you're like ha, we've seen this one just couple of seconds ago, they'll be coerced and whatsoever.
Umm, no, this one is actually whole different story, there's no type coercion involved here, have a second look, they're both of the type array, no coercy operation is involved, the previous one has ![]
which was a coercive operation.
So how this one is handled?
Easily, it invokes the tripe equal operation directly which will fail the first 2 cases of number checks and bigInt check and invoke the SameValueNonNumber
operation.
We can write this check in another form that may look more friendly
console.log(!([] == []))
I think you've seen this one before, like an array is not equal to itself? wtf js
That's actually reasonable, because as the SameValueNonNumber
operation indicates, these two are compared using their memory references, which absolutely different ones.
let posts = []
if (posts) {
// passes
}
if (posts == true) {
// fails? WTF
}
if (posts == false) {
// passes!! aight that's it with js
}
Hopefully this one is new to you, but we've discussed it at #1 wtf, but let's illustrate it.
The if
statement invokes the ToBoolean
operation within its condition body, I think that's already known from your day one in programming.
Due to this behaviour, anything that's not a comparison gets coerced to a boolean value, otherwise we couldn't have this code branch evaluated.
And what an empty array gets coerced to as a boolean? Yeppy, true because it's not in the falsy table.
If so, why tf when compared to true it fails!
Because this a whole different operation, this is a comparison operation, so it coerces both operands to an intermediate type, and surely it starts with the non-primitive type and calls ToPrimitive
on it, and once again, what's the result of coercing an array to a primitive? Yea, an empty string if the array is empty.
So the comparison will be like the following
let posts = []
if (true) {
// passes
}
if ('' == true) {
// fails? WTF
}
if ('' == false) {
// passes!! aight that's it with js
}
And once again, the two operands are of different types, which leads to another coercion operation so we can invoke the triple equal operation, and which one is easier to coerce this time?
You got it right, the empty string, which becomes zero, now the comparison is between number and boolean, so the boolean is also coerced to become a number, so the comparisons end up like the following.
if (0 == 1) {
// fails? WTF
}
if (0 == 0) {
// passes!! aight that's it with js
}
So yeah, sure they're reasonable now.
Note: I may not haven't mentioned it, but the language favors numeric comparisons as they're easier to compute, so it always try to coerce types to their numeric form.
This one is so famous that it was made as a meme, but once again, this is where bugs come from, the divergence of thinking between you and how the language thinks. And guess what, JavaScript is always right of how she thinks.
So let's get into it and analyze what happens here, first of, 0 == "0"
this one is quite easy I hope, a number compared to a string, and we've stated earlier that the language favours the numeric comparisons, so it coerces the string to a number which makes the comparison 0 == 0
which invokes the triple equal operation and returns true.
Secondly, we have 0 == []
, which is again non-primitive or non-numeric compared to a number, so the ToPrimitive
operation is invoked on the array which returns ""
empty string, then the previous operation is repeated and it coerces the empty string to zero, then invokes the triple equal operation which returns true.
Lastly is where the confusion rises, "0" == []
returns false, hopefully you find this logical.
We start off by checking the types, string and array, so the non-primitive type gets coerced to it's primitive form, which makes the array empty string, which makes the comparison "0" == ""
and voila, they're of the same type, the triple equal is invoked with two strings, which invokes the compareUsingUnicode
function we've defined earlier, which checks if the strings share the same unicodes, surely they don't here, its an empty string and a zero as a string comparison, thus it returns false
.
It was a long way to make it here from the very first article, I'm proud of you xD
So let's wrap up what we have discussed within these four articles and my thoughts about them.
Whether the language is nuts or we are for using it, I am quite sure you know how powerful JavaScript is, but it's reasonable that you use a tool and understand how it works, you should have a solid mental model of what the language has to offer, you'd be really missing a useful tool within your tool belt when you say "Coercion is bad, I will let the triple equal handle these cases for me".
I can't claim that coercion doesn't have crazy edge cases in the language, probably they're specific to JS, I don't know about you, but I come from a PHP background, and most of these cases do not exist there, but I can guarantee you, JavaScript has more to offer than PHP, they both differ internally of course, and the intention and use cases behind each language is different, but for me as a web dev, I find JS has more to offer, at least in the back-end division, but sure that's out of subject, I am not comparing languages here.
All I am trying to say that don't ever judge a feature of the language, embrace it and understand it better, that's how you make a better software engineer of yourself and the others around you.
Hope you've liked this series and and found it useful 👼
Have a nice drink and a wish you a very pleasing day, Cheerio 💜
18