JavaScript Coercion : Beyond Basics
Faisal
Posted on December 2, 2023
In JavaScript, we often see implicit type conversion in our code which occurs due to abstract operation. In JS, we use the term coercion for what's commonly known as type conversion. When we consider conversion and coercion, it's best to see them as interchangeable, especially in the context of JavaScript.
Coercion is a weird topic in javascript, that is why many tends to ignore this topic. However, we can't ignore something that behaves counterintuitively. We will start by exploring how abstract operations and coercion occur implicitly. Following that, we'll discuss why they are important.
Here one thing to note that, abstract operations are not like regular functions that you explicitly call in your code. Instead, they represent conceptual operations that help describe the behavior of certain language features.
While there may be internal methods or mechanisms within the JavaScript engine that handle the tasks associated with abstract operations, the key idea is that these operations are more about defining behavior and functionality at a higher level of abstraction.
ToPrimitive Operation
The abstract operation that we will talk about first is called ToPrimitive
or primitive coercion process. The ToPrimitive
operation converts a value to a primitive value, either a string, number, or a default value. It is often invoked implicitly by JavaScript when an object is used in a context where a primitive value is expected.
Let's say we are performing an operation where it requires a value to be primitive. Now, if we don't have a primitive in there, we need to turn that into a primitive.
If we have a non-primitive entity that needs to be transformed into a primitive one, conceptually, what it must follow is a series of algorithmic steps. This set of steps is referred to as ToPrimitive
, almost as if it were a function that could be called.
The ToPrimitive
operation takes an optional type hint
. The hint helps to figure out what it needs to be converted to. Let's say if we are doing a numeric operation and if there needs to run the ToPrimitive
operation, the hint will be something like this - 'I would like it to be a number'. It's just an implicit process where JavaScript understands what type it needs to be. If we do something string-based, it sends a hint as a string. These are the two basic hints. And if it can't figure out the hint, it returns whatever the best primitive value it can return in that context.
One important thing is that -
JavaScript algorithms inherently follow a recursive pattern. For example, if what returns from
ToPrimitive
isn't a primitive type value but instead another non-primitive thing,ToPrimitive
will be called again. It will keep getting invoked until we obtain an actual primitive. If it can't return a primitive value, it eventually results in an error.
The ToPrimitive
operation relies on two methods - valueOf
and toString
. These can be available on any non-primitive value in JavaScript, be it an object, function, array, or any other non-primitive type. These two methods/functions play a crucial role in the process of converting non-primitive values to primitive ones.
Remember, ToPrimitive
operation takes an optional hint.
Now if the hint is number, then ToPrimitive
invokes the valueOf
method first and see what it returns. If valueOf
method gives a primitive, then we're done. However, if it doesn't give a primitive or if the method itself doesn't exist, then it tries the second method - toString
.
If toString
method also fails to give a primitive, it usually leads to an error.
JavaScript calls the
toString
method to convert an non primitive to a primitive value. We rarely need to invoke thetoString
method ourselves; JavaScript automatically invokes it when encountering an object where a primitive value is expected. MDN
If the hint is string, still both valueOf
and toString
method occurs, but in reverse order. In terms of string hint, if it ToPrimitive
operation tries to make a non primitive into a primitive, toString
method invoke first. Then, if it gives us a primitive like a string, we will just use that.
So, no matter what the hint is, if we are trying to use something that is not primitive where it needs to be primitive, like in some math or concatenation, then it goes through ToPrimitive
algorithm and it ends up either invoking the valueOf
method or toString
method.
Diving deep into valueOf method
Let's take an object - const obj = {value: 4}
.
Now, obj + 3
returns us '[object Object]3'
as output. But, shouldn't it be 7? Since the object's value is giving a number hint, it should already be turned into a number, right? Then, why is it behaving this way?
To figure out that answer, we need to understand that, under the hood, JavaScript is still running the ToPrimitive
operation on our written obj
to convert it to work in primitive operations - like adding value. And, since it's taking a number as a hint - as we know, it is running the valueOf
method first. But, the problem is, the default valueOf
method of JavaScript, which gets inherited from Object.prototype
, is basically useless in this case because the valueOf
method inherited from Object.prototype
returns the object itself (this
).
And, since the ToPrimitive
algorithm is recursive, it runs again, and sees that the valueOf
method is not working, so, it falls back into the toString
method. And when the toString
method runs on any object type, it returns - "[object Object]"
So, our object basically is turning into a string first which is "[object Object]"
, Then, when we use the +
operator between two string
types, it just concatenates them.
obj + 3
"[object Object]" + 3
"[object Object]3"
Many built-in objects override the valueOf
method to return an appropriate primitive value. When you create a custom object, you can override valueOf
to call a custom method so that your custom object can be converted to a primitive value when needed.
For example -
const obj = {
value: 4,
}
obj.valueOf() // {value: 4}
It is returning the object itself(this
). But, you can create a function to be called in place of the default valueOf
method. Your function should take no arguments, since it won't be passed any when called during type conversion.
const obj = {
value: 4,
valueOf: function() {
return this.value;
}
};
obj.valueOf() // 4
In this case, it is just returning the value. And, if we try to execute something where our written obj
will need to be a primitive type, we will see that it will behave as expected -
obj + 3
will return 7.
Why? Because, since the adding operation is invoking the ToPrimitive
operation with a number
hint, so, the valueOf
method is running first. But, now, our obj
has its own custom valueOf
method, so it's not executing the inherited valueOf
method from Object.prototype
which returns this
object itself. Instead, for our custom valueOf
method, it's giving us the actual number form of the value, since we are getting a primitive type already, it's not falling into the toString
method.
ToString Operation
Here are some values and their corresponding string values after performing the ToString
operation on them:
-
null
=> "null" -
undefined
=> "undefined" -
true
=> "true" -
false
=> "false" -
3.14
=> "3.14" -
0
=> "0" -
-0
=> "0"
The ToString
operation is executed during abstract operations involving implicit type conversion.
When ToString
is applied to object types like arrays, it returns values as demonstrated in the following examples:
-
[]
=> "" -
[1, 2, 3]
=> "1,2,3" -
[null, undefined]
=> "," -
[ [ [ ], [ ] ], [ ] ]
=> ",," -
[,,,]
=> ",,,"
For objects:
-
{}
=> "[object Object]" -
{a: 2}
=> "[object Object]"
We can modify the default behavior of toString
by implementing a custom toString
method in our object. It will be called when needed:
{toString() { return "A"; }} => "A"
ToNumber Operation
There are a lot of corner cases involved in terms of ToNumber.
If we need to do something numeric and we don't have a number, JS invokes the ToNumber abstract operation. Some conversions are pretty straightforward, while others are counterintuitive.
-
"" => 0
- An empty string returns 0. Empty string should be NaN, right? Empty means there is nothing. How can nothing be 0? That's weird. It's not only a weird case in JavaScript; it creates a lot of other problems in terms of [[Coercion]] in JS.
-
" 003 " => 3
- If there are any empty spaces, JS removes those and returns the corresponding number.
Others are pretty straightforward:
"0" => 0
"-0" => -0
"3.14" => 3.14
"0.0" => 0
-
"." => NaN
- "." turns into NaN, which is as it should.
-
"0xaf" => 175
- Hexadecimal string turns into their corresponding numbers.
false => 0
-
true => 1
- Within the context of programming, maybe it's a sensible thing to turn false and true into 0 and 1, as that's how computers treat them. But, it creates an issue. false and true should turn into NaN. We will see the reason later when we discuss the corner cases.
null => 0
-
undefined => NaN
- Null and undefined both should be NaN, but one turns into 0, and another turns into NaN. That's interesting. For those who think that converting null uses the valueOf method of Object because null is an object, one interesting thing is that null doesn't inherit methods from Object.prototype.
ToNumber Operation on Non-Primitive
When we run ToNumber on a non-primitive or object, it evokes the ToPrimitive
operation with the number hint. In ToPrimitive
operation, it first runs the valueOf
method, which is inherited from the Object.prototype by default, and it returns the object itself (this
). JS ignores that, and it falls into the toString
method.
Since it falls into tpString
, no matter if our hint is a number or not, the value turns into a string. So, we can think of the numberification of an object as the stringification of it.
There are cases where we want something to be a primitive number, and we can end up with a primitive string for the internal process of valueOf
. We need to be aware of that.
If it's empty like []
or {}
, then the same things happen. It evokes the ToPrimitive
, and the valueOf
returns the empty parenthesis - []
or {}
, then it falls into toString
as usual.
Some more ToNumber operations in terms of arrays:
-
[""]
=> 0 -
["0"]
=> 0 -
["-0"]
=> -0 -
[null]
=> 0 -
[undefined]
=> 0 -
[1,2,3]
=> NaN -
[[[]]]
=> 0
Few important things to notice here:
[""]
falls into ToString, and then it returns an empty string. IfToNumber
tries to convert an empty string, it converts it to 0, which is our old problem as we saw earlier.Similar things happen for
[[[]]]
- first, it turns into "" by toString, then, it converts into a number, which is 0. Emptiness shouldn't be 0 in this case.We see that
[null]
and[undefined]
return 0 in the ToNumber operation, which doesn't make sense. We have seen different behavior in their case when we have tried to convert primitive null and undefined. But, in this case, they are returning an identical value, which is 0. Why? In this case, this is a non-primitive thing in both cases, and as it's non-primitive,ToPrimitive
evokes, and the valueOf method runs first. It returnsthis
, then it falls into thetoString
method, and remember whattoString
does on a non-primitive with null or undefined? It turns into an empty string. And then, that empty string turns into 0. Our root problem again.-
{..}
=>NaN
- If it's an object {}, then the same thing happens.
ToPrimitive
evokes, then it falls into toString. ToString returns "[object Object]", which is definitely not a representation of a number, so it returnsNaN
. - But, if we add our custom
valueOf
method, it doesn't fall into toString, and it returns what we want to be returned.
- If it's an object {}, then the same thing happens.
ToBoolean Operation
This is pretty straightforward. We just need to remember all the falsy values, which will always return false if we try to run ToBoolean operation on them. Everything else returns true.
Falsy values: "", 0, -0, null, NaN, false, undefined Truthy values: Everything else.
One thing is important to notice here:
When we try to convert anything to Boolean, it doesn't evoke any other operation under the hood, like
ToNumber
. It just checks its truthiness or falsiness.
That's why if we convert [ ] to a boolean, it returns true, and if we convert "" to a boolean, it returns false. Because an empty string is a falsy value, [ ] is not.
If it would evoke the toString
method under the hood, that would convert [] to an empty string, and then we would get false because of that empty string. But, in this case, that is not happening.
Corner Cases
Every language has type conversion. We can't just avoid coercion in JavaScript for its corner cases. No language is free from various kinds of corner cases. We just need to be aware of those corner cases and learn how to work around them. Here are some of the corner cases we need to be aware of -
Number("") // 0 (!!!)
Number(" \t\n") // 0 (!!!)
Number(null) // 0 (!!!) should be NaN!
Number(undefined) // 0
Number([]) // 0
Number([1,2,3]) // NaN
Number([null]) // 0 -> !!!
Number([undefined]) // 0 -> !!!
Number({}) // NaN
String(-0) // "0" -> what! should be "-0"!
String(null) // "null" -> good!
String(undefined) // "undefined" -> good!
String([null]) // "" -> !!!
String([undefined]) // "" -> !!!
Boolean(new Boolean(false)); // true -> what!!!
One of the root causes of corner cases occurring in coercion in JS is the conversion of "" to 0. We have already seen what kind of problems it can cause. Many coersions would just be gone if an empty string wouldn't turn into 0 in number conversion; if it would be NaN. Whatever, we can't do anything about it now because of backward compatibility.
Another important corner case is true becoming 1, and false becoming 0 in terms of converting to number operation. Number(true)
-> 1 Number(false)
-> 0
Though it is an intuitive thing to us. Let's see how it creates a problem. 1 < 2
-> true; Okay. 2 < 3
-> true; It's also okay. 1 < 2 < 3
-> true. Okay. You are thinking - it's exactly working as it should be.
But that is not the case; what is happening under the hood is it first returns true
for the first part. Then, true converts into 1, since we are performing a numeric operation. Then, 1 < 3
obviously returns true.
(1 < 2) < 3
(true) < 3
1 < 3
We see how this can create a problem.
7 > 5 // true
5 > 3 // true
7 > 5 > 3 // false --> !!!
Let's see, which is actually happening.
(7 > 5) > 3
(true) > 3
1 > 3 // false
Since 1 is not greater than 3, it returns false. Now, we understand how converting true
and false
to 1 and 0 creates problems. It would be solved if it were just NaN.
Some of us hate coercion because of it's weird corner cases. But, we can't ignore coercion. We use coercion all the time.
Practical Use Cases of Coercion
Coercion is a weird topic in JavaScript. But, we can't avoid it either. It turns out, we all deal with coercion all the time! Let's see some examples.
var numPeople = 19;
console.log(`There are ${numPeople} people out there.`);
// There are 19 people out there.
var firstPart = "There are ";
var numPeople = 19;
var secondPart = " people out there.";
console.log(firstPart + numPeople + secondPart);
// There are 19 people out there.
It turns out that if we use the +
operator and any of the values is a string, it converts the entire expression into a string. This is known as Operator Overloading. Our number is implicitly converting into a string. Some may want to make it explicit:
var numPeople = 19;
console.log(`There are ${numPeople + ""} people out there.`);
// There are 19 people out there.
or
var numPeople = 19;
console.log(`There are ${[numPeople].join("")} people out there.`);
// There are 19 people out there.
or
var numPeople = 19;
console.log(`There are ${numPeople.toString()} people out there.`);
// There are 19 people out there.
or
var numPeople = 19;
console.log(`There are ${String(numPeople)} people out there.`);
// There are 19 people out there.
Writing explicit code is not a good decision always.
Some may ask, what about converting a string to a number? We do that too. What about form data? We all need to work with form data as JavaScript developer.
function addOnePeople(numPeople) {
return numPeople + 1;
}
addOnePeople(peopleInputElement.value); // get people number from form
// "191" - WHAT!!!!
So, again, we need to convert it into a number first! There are two ways. First one -
function addOnePeople(numPeople) {
return numPeople + 1;
}
addOnePeople(+peopleInputElement.value);
// 20
or we can use the Number
function, which is the more preferred way.
function addOnePeople(numPeople) {
return numPeople + 1;
}
addOnePeople(Number(peopleInputElement.value));
// 20
How about reducing the input value?
function ignoreOnePeople(numPeople) {
return numPeople - 1;
}
ignoreOnePeople(peopleInputElement.value);
// 18
Since numPeople
is a string and we are trying to perform an operation where a number is needed, the ToPrimitive
process is triggered, and numPeople
converts into a number automatically. That's why we don't need to explicitly mention that. We even need to check truthy and falsy values all the time.
if (peopleInputElement.value) {
numPeople = Number(peopleInputElement.value);
}
Again, if we aren't aware of the corner case of Boolean, we may face a bug here. What if the input element contains a bunch of white spaces? That won't be a falsy value!
Boxing
We have seen that non-primitive values don't have methods in them. If that's the case, how can we access methods in a string like somestring.length
?
It's called Boxing. It's a form of implicit coercion, although it doesn't occur in the same process as abstract operations do.
It's like when JavaScript sees that you are trying to run a method on a non-primitive value, JavaScript does you a favor. It thinks, "Okay, this person is trying to run an operation as if it's a primitive value (object); let's turn it into that and make their life easier!" And then, it converts it into the corresponding object representation of the string.
Maybe from this, the falsy notion comes: In JavaScript, everything is an object. No, just because it is converting into an object-like structure under the hood doesn't mean it's an object. Things can behave as similar, but that doesn't make them similar.
Two things are pretty different.
A significant part of my motivation to delve deeply into JavaScript was influenced by Kyle Simpson. I conclude here by sharing some of his philosophy on "coercion."
- You don't deal with corner cases by avoiding coercions.
- You have to adopt a coding style that makes value types plain and obvious.
- A quality JS program embraces coercions, making sure the types involved in every operation are clear. Thus, corner cases are safely managed.
- JavaScript's dynamic typing is not a weakness, it's one of it's strong qualities.
- Implicit doesn't mean magic. It means abstraction. So, that it can hide unnecessary details, re-focusing the reader and increasing clarity.
- We can simiply depends on implicit type coercion rather than always describing type convertion explicitely every time, which will decrease readability.
It's "Useful", when the reader is focused on what's important. It's "Dangerous", when the reader can't tell what will happen. It's "Better", when the reader understands the code.
It's "Irresponsible" to knowingly avoid usage of a feature that can improve code readability.
And it's always better to learn how things work under the hood!
Happy Learning!
If you enjoyed reading this, you can connect with me on Twitter or check out my other articles.
Additional Resources
JavaScript Data Structures - MDN
valueOf - MDN
valueOf in JavaScript - ECMA
Abstract Operations: To Primitive - ECMA
You Don't Know JS by Kyle Simpson
Posted on December 2, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024
November 27, 2024