The weirdness of type coercions in JavaScript explained

Published on

Holy Trinity

Many people hate JavaScript because of its quirks and most of them do because of the insanity of the type coercions. As I discussed here, JavaScript indeed has quirks, design flaws and inconsistencies. Type coercions can also be a footgun but in my opinion most of the insane examples of such coercions shown on the internet are more artificial than accidental, it's not very likely that you'll use them accidentally, especially with the right habits, such as avoiding using the == operator in favour of the === operator as much as possible.

Here I will primarily discuss about the loose equality operator == and some basic type coercions.

How the loose equality works

Loose comparison can be split into 2 main cases, one is where the typeofs of the both operands match, the other is where they don't.

Where typeof is the same for both operands

This is the simple case. If typeofs of the both operands match, then the comparison works exactly like === (the strict comparison):

  1. If both operands are primitive values (null, undefined, boolean, number, string, bigint, symbol), then their values are just compared. As with strict comparison, the only value that doesn't equal to itself is NaN.
  2. If both operands are non primitive values (object, function), then their references are just compared.

Where typeof is different for both operands

This is where the most interesting things begin.

  1. Loose comparison is symmetric, which means if you swap operands the result will not change. a == b will be the same as b == a.
  2. If one of the operands is undefined or null the other should also be undefined or null in order to yield true.
  3. If one of the operands is symbol and the other is not, the yielded value is always false.
  4. If both operands are non primitive values (object, function), similarly to the case where typeofs of the both operands match, then their references are just compared.
  5. If one of the operands is a primitive type and the other is not, then the primitive value will be loosely compared to the non primitive value converted into a primitive value. The conversion from non primitive to primitive value works like this:
    1. If the non primitive value has method [Symbol.toPrimitive]() then the converted primitive value is the return value of [Symbol.toPrimitive]("default") method. "default" is the hint parameter which can have values "number", "string", "default".
    2. If the non primitive value has no method [Symbol.toPrimitive](), but has method valueOf(), then the converted primitive value is the return value of valueOf() method. If the return value is not primitive then the next steps are checked.
    3. If the non primitive value has no methods [Symbol.toPrimitive]() and valueOf(), but has method toString(), then the converted primitive value is the return value of toString() method.
    4. If the non primitive value lacks [Symbol.toPrimitive](), valueOf() and toString() at the same time, then TypeError will be thrown.
  6. If both operands are primitive values the comparison is done like this:
    1. If one of the operands is boolean but the other is not, the boolean is converted into a number. true is converted into 1, and false is converted into 0. Then the operands are loosely compared again.
    2. If one of the operands is string and the other is number or bigint then the string operand is parsed and converted into the respective type of number and the operands are compared again. If the conversion fails the result is obviously false. Note that in a case of bigint if the string is written in a floating point format (such as "1.0"), the conversion will still fail despite that the value is an integer.
    3. If one of the operands is number and the other is bigint, the operands will be compared by their numeric values. If the number operand isn't a finite integer, the result is obviously false.

Explaining "The Holy Trinity" of comparisons

Now we can explain this "The Holy Trinity" of comparisons from the picture:

console.log([] == "0") // [] == "0" ----> "" == "0" ----> false // Since valueOf [] is the array itself, toString() is called // which returns the elements concatted by ",", thus it's "" because no elements are there. console.log([] == "\t") // [] == "\t" ----> "" == "\t" ----> false console.log("0" == "\t") // false, nothing to explain here console.log("0" == 0) // true, "0" == 0 ----> 0 == 0 ----> true console.log([] == 0) // true, [] == 0 ----> "" == 0 ----> 0 == 0 ----> true // "" is parsed as 0 console.log("\t" == 0) // true, "\t" == 0 ----> 0 == 0 ----> true // "\t" is also parsed as 0 since leading / trailing space / tab characters are ignored

Some other type coercions

There are other operations besides equality, I'll also discuss a bit about them.

There are 2 types of operations that involve:

  1. Unary, when there is only one operand, for example +"5" or -"3".
  2. Binary, when there are 2 operands and there is an operation between them, for example "10" - 1.

Unary operations have higher priority, so in the expression +"5" + 10 +"5" will be executed first (it will convert to number).

Primitive values have hierarchy. When there is a type coercion performed between 2 operands, they are converted into the highest common hierarchy type. The hierarchies are the following:

So, for example, the highest common hierarchy type for string and number is string, for null and undefined is number, etc.

Operations have these main rules:

  1. Every operation result is either number, bigint, string or boolean. If an operand has a different type, its type should be lowered to one of these types.
  2. If in both unary (other than !) and binary operations symbol is involved, it will throw TypeError.
  3. If there is a binary operation between bigint and non bigint primitive value and that operation is not a comparison (==, ===, <, <=, >, >=), it will throw TypeError.
  4. If there is a binary operation + and one of the operands has a different type or one or both of the operands are not number or string, the operands will be converted into the highest common hierarchy type which is either number or string.
  5. If there is any unary operation or some binary operation that doesn't involve + and operands have different types, the operands are converted into number first.
  6. When ! unary operator is used and the operand is falsy (false, 0, -0, 0n, "", null, undefined, NaN, document.all), the result is true, otherwise false.
  7. When null is converted into number the value is 0.
  8. When null is converted into string the value is "null".
  9. When undefined is converted into number the value is NaN.
  10. When undefined is converted into string the value is "undefined".
  11. When object / function is converted into number it tries to return [Symbol.toPrimitive]("number"), valueOf() if [Symbol.toPrimitive]() doesn't exist or toString() if both [Symbol.toPrimitive]() and valueOf()don't exist.
  12. When object / function is converted into string it tries to return [Symbol.toPrimitive]("default"), valueOf() if [Symbol.toPrimitive]() doesn't exist or toString() if both [Symbol.toPrimitive]() and valueOf()don't exist.

Disclaimer, this is heuristics and it's a bit simplified, since there are some edge cases. But it's ok for basic understanding.

Examples

class A { valueOf() { return 1; } toString() { return 'str'; } [Symbol.toPrimitive](hint) { switch(hint) { case 'string': return this.toString(); case 'number': return this.valueOf(); default: return this.toString(); } } } const obj = new A(); console.log(null + true); // 1, null + true -----> 0 + 1 -----> 1 console.log(obj + obj); // 'strstr', obj + obj -----> 'str' + 'str' -----> 'strstr' console.log(null + true); // 1, null + true -----> 0 + 1 -----> 1 console.log(obj + null); // 'strnull', obj + null -----> 'str' + null -----> 'strnull' console.log(+obj + null); // 1, +obj + null -----> 1 + null -----> 1 + 0 -----> 1 console.log(+obj + obj); // '1str', obj + obj -----> 1 + obj -----> 1 + 'str' -----> '1str' console.log(obj - obj); // 0, obj - obj -----> 1 - 1 -----> 0 console.log(null + true); // 1, null + true -----> 0 + 1 -----> 1 console.log(null + undefined); // NaN, null + undefined -----> 0 + NaN -----> NaN console.log(null + ''); // 'null', null + '' -----> 'null' + '' -----> 'null' console.log(null * ''); // 0, null * '' -----> 0 * 0 -----> 0




Read previous