The weirdness of type coercions in JavaScript explained

Published on
Updated 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 favor 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 comparisons can be divided 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

Logical operations

JS has 3 logical operators: && (logical AND), || (logical OR) and ! (logical NOT).

&& and || are binary, which means there are 2 operands and there is logical between them. ! is unary, there is only one operand. ! has higher priority than && and && has higher priority than ||.

! yields true if the value is falsy (false, 0, -0, 0n, "", null, undefined, NaN, document.all), otherwise it will yield false.

When there is a chain of &&s, it will yield the first falsy value or the last non falsy value if no falsy value was found.

When there is a chain of ||s, it will yield the first non falsy value or the last falsy value if no truthy value was found.

Examples

console.log(!1); // false console.log(!""); // true console.log(!0); // true console.log(!10); // false console.log("" || 1); // 1 console.log("" || 0); // 0 console.log(2 || 0); // 2 console.log(2 || 3); // 2 console.log("" && 1); // "" console.log("" && 0); // "" console.log(2 && 0); // 0 console.log(2 && 3); // 3

Arithmetic coercions

There are also arithmetic coercions, 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).

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

Imagine that 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 (==, ===, <, <=, >, >=) or a concatenation with a string, 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 +, || or &&, the operands are converted into number first.
  6. When null is converted into number the value is 0.
  7. When null is converted into string the value is "null".
  8. When undefined is converted into number the value is NaN.
  9. When undefined is converted into string the value is "undefined".
  10. When true is converted into number the value is 1.
  11. When false is converted into number the value is 0.
  12. 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.
  13. 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.

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(obj + obj); // 'strstr', obj + obj -----> 'str' + 'str' -----> 'strstr' 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


UP
This site uses cookies. By continuing to use this website, you agree to their use. To find out more, including how to control cookies, see here: Privacy & cookies.