The weirdness of type coercions in JavaScript explained
Published on

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 typeof
s 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 typeof
s of the both operands match, then the comparison works exactly
like ===
(the strict comparison):
-
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 isNaN
. -
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.
-
Loose comparison is symmetric, which means if you swap operands the result will not change.
a == b
will be the same asb == a
. -
If one of the operands is
undefined
ornull
the other should also beundefined
ornull
in order to yieldtrue
. -
If one of the operands is
symbol
and the other is not, the yielded value is alwaysfalse
. -
If both operands are non
primitive values (
object
,function
), similarly to the case wheretypeof
s of the both operands match, then their references are just compared. -
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:
- 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 thehint
parameter which can have values"number"
,"string"
,"default"
. -
If the non primitive value has no method
[Symbol.toPrimitive]()
, but has methodvalueOf()
, then the converted primitive value is the return value ofvalueOf()
method. If the return value is not primitive then the next steps are checked. -
If the non primitive value has no methods
[Symbol.toPrimitive]()
andvalueOf()
, but has methodtoString()
, then the converted primitive value is the return value oftoString()
method. -
If the non primitive value lacks
[Symbol.toPrimitive]()
,valueOf()
andtoString()
at the same time, thenTypeError
will be thrown.
- If the non primitive value has method
-
If both operands are primitive values the comparison is done like this:
-
If one of the operands is
boolean
but the other is not, theboolean
is converted into a number.true
is converted into1
, andfalse
is converted into0
. Then the operands are loosely compared again. -
If one of the operands is
string
and the other isnumber
orbigint
then thestring
operand is parsed and converted into the respective type of number and the operands are compared again. If the conversion fails the result is obviouslyfalse
. Note that in a case ofbigint
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. -
If one of the operands is
number
and the other isbigint
, the operands will be compared by their numeric values. If thenumber
operand isn't a finite integer, the result is obviouslyfalse
.
-
If one of the operands is
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:
-
Unary, when there is only one operand, for example
+"5"
or-"3"
. -
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:
-
object
>string
(the hint for[Symbol.toPrimitive]()
will be"default"
which is oftentimes interpreted as"string"
, but it can have custom behavior) -
function
>string
-
bigint
>string
-
null
>number
>string
-
boolean
>number
>string
-
undefined
>number
>string
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:
-
Every operation result is either
number
,bigint
,string
orboolean
. If an operand has a different type, its type should be lowered to one of these types. -
If in both unary (other than
!
) and binary operationssymbol
is involved, it will throwTypeError
. -
If there is a binary operation between
bigint
and nonbigint
primitive value and that operation is not a comparison (==
,===
,<
,<=
,>
,>=
), it will throwTypeError
. -
If there is a binary operation
+
and one of the operands has a different type or one or both of the operands are notnumber
orstring
, the operands will be converted into the highest common hierarchy type which is eithernumber
orstring
. -
If there is any unary operation or some binary operation that doesn't involve
+
and operands have different types, the operands are converted intonumber
first. -
When
!
unary operator is used and the operand is falsy (false
,0
,-0
,0n
,""
,null
,undefined
,NaN
,document.all
), the result istrue
, otherwisefalse
. -
When
null
is converted intonumber
the value is0
. -
When
null
is converted intostring
the value is"null"
. -
When
undefined
is converted intonumber
the value isNaN
. -
When
undefined
is converted intostring
the value is"undefined"
. -
When
object / function
is converted intonumber
it tries to return[Symbol.toPrimitive]("number")
,valueOf()
if[Symbol.toPrimitive]()
doesn't exist ortoString()
if both[Symbol.toPrimitive]()
andvalueOf()
don't exist. -
When
object / function
is converted intostring
it tries to return[Symbol.toPrimitive]("default")
,valueOf()
if[Symbol.toPrimitive]()
doesn't exist ortoString()
if both[Symbol.toPrimitive]()
andvalueOf()
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