Objects, Primitives, Conversion, Why?!
JavaScript is a dynamically typed language.
At one moment, your variable is a string, and in the next moment and one reassignment later, you have a number. And you don't even necessarily always know which type a variable has, as JavaScript doesn't know type annotations due to its dynamic nature.
That's how JavaScript always worked and will work forever. And to be hontest, that isn't even bad. It can only become a little confusing as your code base grows and with the existence of more and more possible execution paths.
And its dynamic nature is what actually requires the so-called type coercion.
Whenever you use a variable in an operation, the runtime has to handle different types being out as operands.
And this is where type coercion and conversion kicks in and happens.
Why Should You Care?
Especially function parameters are prone to being anything at any time. It's not always obvious to a user what happens within a function.
You don't really need to care, but there will come the time that a function like the following one:
function doCalculation(a, b) {
return a + b;
}
is actually called like this:
const result = doCalculation({}, [1, 2, 3]); // => ???
And that's the time where it could potentially help you knowing how type coercion and especially primitive conversion in JavaScript works.
When Primitive Conversion Happens
Primitive conversions happens every time an object is involved in any operation. It is an integral part of JavaScript's type coercion.
There are a lot of operations, JavaScript knows of, for example:
- The addition (+)
- The subtraction (-)
- The multiplication (*)
- The division (/)
- The remainder (%)
- And a lot more.
In most of them, if not all, objects are first converted to primitives before the actual operation is performed.
How Primitive Conversion Works
Since ES2020, JavaScript runtimes know three built-in methods to convert an object into a primitive. And the order in which they are called is pretty well-defined, so let's get into the algorithm.
The algorithm
The algorithm is defined as: ToPrimitive(input [, preferredType])
and goes as follows:
- If Type(
input
) is Object, then- If
preferredType
is not present, sethint
to"default"
- Else If
preferredType
is hint"string"
, sethint
to"string"
- Else, set
hint
to"number"
- Set
conversionMethod
toinput[Symbol.iterator]
- If
conversionMethod
is NOTundefined
, then- Set
result
toconversionMethod(hint)
- If Type(
result
) is NOT Object, then- return
result
- return
- Throw a
TypeError
exception
- Set
- Set
methodNames
to["valueOf", "toString"]
- For each
name
in ListmethodNames
, in order, do- If
input[name]
exists, then- Set
result
toinput[name]()
- If Type(
result
) is NOT Object, then - return
result
- Set
- If
- throw a
TypeError
exception
- If
- return
input
Explanation
The algorithm above may seem a little intimidating, but let's break it down a little and explain in plain English what actually happens.
Any JavaScript runtime knows three built-in methods it will try to call for the conversion.
Those are:
The method toPrimitive(hint)
The first one that is being called is toPrimitive(hint).
So, as soon as you define that method within one of your objects, the runtime will use this one for the conversion.
The interesting thing about this method is, that it accepts a type hint, which allows you to react to that hint in your own implementation.
You can still ignore it, but having the opportunity is really great, as it allows for greater flexibility.
Please note, that if Symbol.toPrimitive
exists as a callable function on any object, it will be called.
And if that call does not return a primitive, a TypeError will be thrown.
The runtime does not fall back to calling other methods in that case!
toPrimitive Example
const obj = {
[Symbol.toPrimitive]: function(hint) {
if (hint === "number") {
return 0;
}
return "";
}
};
The method valueOf()
The first method that will be called , when no callable function at Symbol.toPrimitive
is present, is valueOf
. It is usually expected to return a number, but it doesn't have to do so.
Calling [].valueOf()
returns []
for example.
If valueOf()
returns any primitive, the executor is satisfied and will use that value in any operation the object was an operand of.
valueOf() Example
const obj = {
valueOf: function() {
return 0;
}
};
The method toString()
The last method that the executor calls is toString
. It is expected to return a string, but once again, due to JavaScript's dynamic nature, it does not have to do so!
It is the fallback, that is called when no valueOf
is present.
If you don't define this yourself, the original toString() method, attached to the base Object, is used, which usually returns a string of format:
[object <type/>]
toString() Example
const obj = {
toString: function() {
return "string representation";
}
};
How You Can Use This
Implementing It And Using It To Make Objects Usable With Operators
If you want your objects to be usable in a meaningful manner, you should implement a callable method under Symbol.toPrimitive
and react to the hint accordingly.
This however requires you to either target min. ES2020 directly, or use polyfills (with babel e.g.), so it also becomes available when you target older versions of JavaScript.
If you can't do this for any reason, implement valueOf()
and toString()
for your objects.
This is also the way how you could potentially satisfy some pretty weird looking quizzes, like the following one:
// Can you obj somehow satisfy all three conditions?
if (obj == 1 && obj == 2 && obj == 3) {
...
}
And the answer is, yes, you can, like this:
const obj = {
value: 0,
[Symbol.toPrimitive]: function() {
return obj.value++;
}
}
Preventing Type Coercion And Primitive Conversion
If you want to prevent your objects from being coercible at all, you can just prevent it by implementing toPrimitive(hint)
like so:
const obj = {
value: 0,
[Symbol.toPrimitive]: function(hint) {
throw new TypeError('Prevented type coercion from happening by preventing primitive conversion.');
}
}
Liked My Article?
If you liked this article, you maybe like the micro content I usually post on Twitter.