DEV Community

Cover image for JavaScript weird type system - Double equal sucks
Ahmed Osama
Ahmed Osama

Posted on

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.

Abstract equality operator

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?

Old version of TC39 spec about double equal operator

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 */
}
Enter fullscreen mode Exit fullscreen mode

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.

Abstract equality - Case 2 and 3

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Abstract equality - case 4 and 5

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Abstract equality - case 6 and 7

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

Abstract equality - case 8 and 9

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Strict equality operator

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 isStrictlyEqual operation from TC39 spec

The same cycle repeats itself, the triple equal performs some checks against the two given values and reacts according to them.

Strict equality - case one

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Strict equality - case two

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
}
Enter fullscreen mode Exit fullscreen mode

Strict equality - case three

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
}
Enter fullscreen mode Exit fullscreen mode

Strict equality - case four

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.

SameValueNonNuemric operation

Same value non numeric operation from TC39 spec

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.

Same value non numeric - case two

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Same value non numeric - case three

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Same value non numeric - case four

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Same value non numeric - case five

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Same value non numeric - case six and seven

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.

IMAGE ALT TEXT HERE

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.

WTF JavaScript #1

console.log([] == ![]) // true
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

And finally, they're both of the type number so the triple equal operation is invoked given these two inputs which returns true ofc.

WTF JavaScript #2

console.log([] != [])
Enter fullscreen mode Exit fullscreen mode

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(!([] == []))
Enter fullscreen mode Exit fullscreen mode

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.

WTF JavaScript #3

let posts = []

if (posts) {
  // passes
}

if (posts == true) {
  // fails? WTF
}

if (posts == false) {
  // passes!! aight that's it with js
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

WTF JavaScript #4

WTF JavaScript as a meme

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.

Conclusion

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 💜

Farewell

Consider supporting/following me

Top comments (0)