@nurbolatk

Understanding type coercion in JavaScript

Have you ever felt unsure about whether your code might break when casting different types? Perhaps you avoid coercion altogether because it feels unpredictable? Or maybe you'd like to confidently answer quirky JavaScript questions like this:

// what is the result of the following operation?
12 + []

Well, I have good news for you! The time to master this concept has come.

This post demystifies the "magic" JavaScript does with types behind the scenes.

Types in JavaScript

JavaScript has seven primitive types:

  1. number
  2. bigint
  3. string
  4. boolean
  5. undefined
  6. null
  7. symbol

All other values, including arrays and functions, are objects.

You can inspect any value's type by calling typeof function. It returns the type of a value as a string.

Behind Type Conversion

How does JavaScript decide how to convert one type to another? According to the JS specification, it uses so-called abstract functions, the underlying mechanisms that JS calls when performing type conversions, and there are several of them, but the most interesting ones are: ToPrimitive(), ToBoolean(), ToString(), and ToNumber().

Let's unpack them one by one.

toPrimitive(hint)

It can be used to convert a non-primitive value to a primitive. By providing hint argument you can specify whether a number or a string is preferred.

hint can be "number", "string", or "default". Passing the hint doesn't guarantee to return the type hint was asking for, it will return the first primitive it was able to get. The first two differ by the order of operations:

  • Hint: number
    • Calls valueOf(). If the result is not primitive, calls toString().
  • Hint: string
    • Calls toString(). If the result is not primitive, calls valueOf().
  • Hint: default
    • Behaves like number unless overridden (e.g., by Date objects, which treat default as string).

If no primitive is obtained after calling both operations, an error is thrown. But as you are going to learn later, almost every value could be converted to a primitive.

toString()

Any value can have a string representation.

null -> "null"
undefined -> "undefined"
2.33 -> "2.33"
true -> "true"
0 -> "0"
-0 -> "0"
null -> "null"

For objects, the toString() function calls toPrimitive("string"). Be extra careful when converting -0 to a string: it will lose its sign.

Examples of object stringification:

// arrays:
[] -> ""
[1,2,3] -> "1,2,3"
[undefined,null] -> ","
[[],[],[[],[],[]]] -> ",,,,"

Arrays print out their items (unless they are null or undefined) with commas and remove all the brackets. In most cases, stringifying arrays is not helpful.

// functions
function sum(a, b) {
  return a + b
}
sum -> "function sum(a, b) {\n  return a + b\n}"

functions print out their source code.

// objects
{} -> "[object Object]"

Other objects print this string. You can change this behavior by overriding the toString() method:

{
  toString() {
    return 'Object A'
  }
} -> "Object A"

toNumber()

"" -> 0
"    " -> 0
"\t\n\n\n" -> 0
"0" -> 0
"-0" -> -0
"2.33" -> 2.33
"  -007 " -> -7
"  00 7 " -> NaN
"strings without numbers" -> NaN
"strings with numbers 777" -> NaN
"Infinity" -> Infinity

true -> 1
false -> 0
null -> 0
undefined -> NaN

As you can see, not all values have a numeric representation.

The most interesting cases are empty strings, string full of whitespace characters, and nulls -> they both return 0.

Similarly to toString(), toNumber() calls toPrimitive("number"), which in turn calls the valueOf() method.

Objects:

By default valueOf() of objects and arrays will return themselves:

{
  valueOf() {
    return this
  }
}

Which, recall, will make toPrimitive() call the next function - toString().

The numerification of an object is basically converting its stringified version.

Consider this:

// arrays
[""] -> 0
["-0"] -> -0
[1,2,3] -> NaN
[null] -> 0
[undefined] -> 0

// any object
{...} -> NaN

Let's take a short quiz to test our understanding.

What would be the results of these calls?

console.log([null, 1, undefined].toString())
Show answer
,1,
console.log(Number([null, 1, undefined]))
Show answer
NaN

toBoolean()

Implicit coercion of a value to a boolean happens every time you need a boolean value:

  if (data) { ... }

Unlike the previous two abstract operations, toBoolean() doesn't call toPrimitive(). It just has a list of values that should return a false:

// List of falsy values
false -> false
"" -> false // empty string
0 -> false
-0 -> false
NaN -> false
null -> false
undefined -> false

Everything else is a truthy value.

The difference between == (double equals) and === (triple equals)

Many of us believe that == is too unpredictable, has a higher chance of creating bugs, and should always be avoided. However, understanding how it works is not difficult and by doing so you will be able to write better code. Why?

Because I frequently end up recreating the abstractions with the === that the == already has.

Let's first look at the implementation of the == that we can always check in the specifications:

JS Specs screenshot

The first thing to note: == will call === internally when the operands' types match! So there is no difference when you do this:

2 == 2 is 2 === 2

The second thing to note: Both == and === will compare references when both operands are objects.

const obj1 = {
  name: 'Arman'
}

obj1 == {
  name: 'Arman'
} -> false

The third thing to note: == treats undefined and null as indistinguishable. It means you could shorthand this:

if(data !== undefined && data !== null) {...}

to:

if(data != null) {...}

The fourth thing to note: For other types, == will prefer converting values to numbers.

So the algorithm is simple:

  1. If one of the values is an object, convert it to a primitive by calling toPrimitive("default"). Keep calling it until we have 2 primitives
  2. If the types are equal, use triple equals.
  3. Otherwise, coerce recursively according to these rules:
  4. If one of the values is a string, call toNumber() on it and try comparing again.
  5. If the other value is a boolean, convert them to a number and try comparing again.

Once again, == prefers converting values to numbers.

When === (triple equals) lies?

It lies in 2 cases:

NaN === NaN -> false
0 === -0 -> true

We can use isNaN() or Number.isNaN() to handle NaNs and Object.is(valueA, valueB) to handle both cases.

The difference between isNaN() and Number.isNaN() is that isNaN() first calls toNumber() to its argument:

isNaN("Not a NaN") -> true
Number.isNaN('Not a NaN') -> false

If you want the strictest check, use Object.is()

Practice questions:

// What will be the output?
console.log([-0] == -0)
Show answer
true
Why
  1. [-0] will be converted to a string: "0" (Remember that toString() of -0 is 0)
  2. "0" will be converted to a number: 0
  3. At this point 0 and -0 will have the same type and it will trigger triple equals: 0 === -0
  4. Remember that === is supposed to lie in this situation and return true
// What will be the output?
console.log(true === new Boolean(true))
Show answer?
false
Why

because new Boolean(true) creates an object, which won't be equal to the primitive true value

// What will be the output?
console.log(1 < 2 < 3)
Show answer?
true
Why
  1. it will first evaluate 1 < 2 which returns true.
  2. now compare true < 3. < also prefers converting values to numbers. So true will be casted to 1
  3. lastly, compare 1 < 3, which is true
// What will be the output?
console.log(3 > 2 > 1)
Show answer?
false
Why
  1. it will first evaluate 3 > 2 which returns true. 2. now compare true > 1. > also prefers converting values to numbers. So true will be casted to 1 3. lastly, compare 1 > 1, which is false

Math operations

Addition (+)

What happens when we try to add a string to a number?

console.log(12 + '1') // => "121"

What about this?

console.log({} + 0) // => "[object Object]0"

The reason is that, according to the JavaScript specs, it will cast the operands to primitives (it calls the toNumber()). If either of the resulting values is a string, cast both to strings and concatenate. Otherwise, cast both of them to numbers and perform addition.

That is why we can do this:

const obj1 = {
  valueOf() {
    return 10
  },
}

console.log(obj1 + 5) // => 15
  1. Non of the operands are strings, so it will call toNumber() to obj1, which invokes the obj1's valueOf() method.
  2. This method returns the number 10
  3. Which will result into a sum of numbers.

Subtraction (-) and other operations

All other mathematical operations accept only numbers, so it will try to cast everything to numbers.

console.log([[[]]] - 3) // => -3
console.log([23] % 5) // => 3
console.log('ABC' ** 3) // => NaN

Performing any math operation with NaN will result in NaN

Suggestions

  1. Use functions Number(), String(), and Boolean() without new if you want to explicitly convert types
  2. Both == and === should only be used when you know the types of values beforehand. If you don't know the types, change your code to know them before you use the comparison. Swapping == with === in this situation won't help; it will indicate that you don't trust your code and you just want to be extra careful here. By choosing to write a code of unpredictable types your code would suffer.