Bringing types to JS. Magic or simple rules?
Full guide
This material, first of all, will not only help you pass the interviews for the Frontend developer position, but it will clarify to you personally how and why the magic in JS works after all. This article can make your life as a js programmer easier. Here’s a challenge for you:
const bool = new Boolean(false);
if (bool) console.log(bool);
if (bool == false) console.log(bool);
What will it print?
Going ahead, both conditions will work, but the output will be something like Boolean{false} everywhere. Why is that? Bad and unclear JS? No, JS is very good and understandable, you just need to understand it all.
In fact, writing this material is preparation for one nice and interesting topic related to polymorphism and overloading in JS. But when I wrote articles about overloading and polymorphism I realized that there are many things that need to be explained before I tell about them otherwise it will be difficult. So we’ll go in order.
The type conversion system in JS is very simple, although you wouldn’t say so at first sight. It’s very different from other programming languages — this is a fact, which is why for many programmers who come from other programming languages to JavaScript often causes cognitive dissonance. But if you figure it out, it’s really simple. You have to learn that there are only 3 type conversions in JS:
- String
- Numeric
- Logic
String conversions are used when you want something to be represented as a string. It’s pretty straightforward.
Numerical conversion occurs in mathematical expressions and also in comparison.
Logic conversion — conversion to true and false, occurs in a logical context (such as conditions) and when applying logical operators. All values that can be treated as empty become false: 0, undefined, null, NaN, empty string.
Everything else, including any objects, becomes true.
In JavaScript, logical transformation is especially interesting in its combination with numerical transformation.
In order not to write Boolean, I will replace it with double conversion !!
— but it does not change the essence and behavior.
An example of this ambiguity (if you don’t know the rules):
const a = 0;
const b = ' 0 ';console.log(!!a, !!b); // false, true
console.log(a == b); // true
The value of a is false in the logical context, since 0 is intuitively empty, as written above. The value of b is not empty in a logical context, because it contains a string of 3 characters (2 spaces and 0).
And when we compare 0 and “ 0 “ we are not comparing them in logical context, but in numerical context, so we have this type conversion comparison:
Number(0) == Number(" 0 ")
So all you need to remember is that there are 3 types of conversions in JavaScript:
- String() — conversion to a string in a string context (e.g. when concatenating strings).
- Number() — a conversion to a primitive in a numeric context, including unary plus (+value). Occurs when comparing different types.
- Boolean() — the conversion to a logical type in a logical context.
A special case is the check for equality of special types such as Null and Undefined. They are equal only to each other and unequal to everything else. This is spelled out in the specification.
null == null
undefined == undefined
undefined == null
null == undefined
Going back to our problem above, so why does the code work that way?
If you have read the article carefully, you can now explain it for yourself. Let’s check it out. It was given:
const bool = new Boolean(false);
if (bool) console.log(bool);
if (bool == false) console.log(bool);
We use class Boolean, which creates not a primitive, but an object — an instance of class Boolean. For such objects JS has its own model of object handling in logical context, as described above. If bool is an object, then in logical context, no matter what kind of object it is or what class it comes from, it will always be cast to true:
Boolean(bool) // = true
// so
if (bool) // = true
And everything is logical and everything obeys the rules. There’s no magic. At the same time, if we make a comparison, then a numerical comparison will be triggered, which will call the “magic” method valueOf
:
bool.valueOf() == false
// поэтому
bool == false // true
if (bool == false) // true
We will talk about magical methods in the next article. And so you see that everything is logical and everything obeys the rules. There is no magic.
Why do you need to know that?
No, not so you can get interviews. Understanding how your programming language works, how it works, and why it works is the difference between a true professional programmer and a craftsman. Understanding such features, allows you to cut down on finding and fixing bugs many times over. Allows you not to spit and say what a bad language is, and do things that initially seem impossible. It’s trivial for general development. As M.V. Lomonosov said:
“Mathematics should already be taught for the fact that it puts the mind in order.”
© M.V. Lomonosov
If you want to develop, you should understand how your tool is structured. In other programming languages, this is the norm. The norm is to know your tool thoroughly. But you don’t have to, yes.
You can argue with me and say that you can work and not know any such thing. But, to give you an example, I can give you the following. I have several vacancies at the moment where you need precisely profound knowledge of JS.
What else is there to know
“Where there is no exact knowledge, there are guesses,
and nine out of ten guesses are mistakes.”
This material will be useful for those who are planning to be interviewed, as well as for those who are already working with JS and want to better understand it.
I mentioned above in the article that there is no magic and uncertainty in JS. Everything obeys the rules and if you know the basic rules you won’t have problems with JavaScript, you don’t have to learn tricks like {}+{}
and there’s no “WAT?!” for you.
toString
If an object implements a toString method that returns a primitive, then that method is used for conversion, otherwise the method from the parent prototype is called (well not really, the actual method will be explained below).
The toString method doesn’t have to return exactly a string, the result of toString can be any primitive. Therefore, this method is not called “conversion to string”, but “string conversion”, because it is not a fact that the output will be a string:
var obj = { toString() { return "200" } }
obj + 1 // '2001'
obj + 'a' // '200a'// and a completely different resultvar obj = { toString() { return 200 } }
obj + 1 // 201
obj + 'a' // '200a'
This is the first mistake you encounter in a job and in an interview: toString can return more than just a string!
But!
var obj = { toString() { return [] } }TypeError: Cannot convert object to primitive value
Only primitives, no objects!
valueOf
valueOf
is a numeric transformation. It is used to numerically convert an object and is always called before toString. If it does not exist, it looks for toString.
The method valueOf
must return a primitive, otherwise its result will be ignored — this is in simple terms. If more precisely, it is a little more complicated:
vlueOf
is searched for- if
valueOf
is not found or did not return a primitive,toString
is searched - if
toString
is found — it is called - if
toString
is not found, it looks forvalueOf
in parent.prototype and repeats…
But!
And don’t forget that after calling methods to convert primitives, if the left and right operands are still of different types, the mechanisms described at the beginning of the article start working. If, for example, the result of these functions is a number and you add it to a boolean, the conversion will continue.
The beauty of valueOf
is that almost no one has it and everyone has toString implemented by default. There is only one exception that has valueOf implemented and this method is called independently of toString
.
Exceptions
For historical reasons, the Date object is an exception. The point is that most objects don’t have valueOf
implemented by default and are called toString. But Date has both toString and valueOf
implemented:
''+new Date // 'Thu Mar 07 2018 21:14:50 GMT+0300'
+new Date // 1551982490848
The exception here is also that addition with string calls toString, although logically it should always be called valueOf, which distinguishes Date object from the others. Again, this is not magic, this is a rule (or rather an exception) which should be remembered and that would be enough…
- Or not an exception? Can you create an object similar in behavior to Date?
It’s hard for me to tell how the Date object was implemented before, but its behavior can be replicated today. The solution to this problem will be just below.
- And how do valueOf and toString work with strict equals? Hmm?
- That’s a great question!
console.log(
1 == { valueOf: _=>console.log('detect') }
)// print 2 lines: detect and false
// and if:console.log(
1 === { valueOf: _=>console.log('detect') }
)// print 1 line: false
In strict comparisons, magic methods are not invoked. Remember, let’s move on. Although it’s in the “exceptions” block, it’s not an exception. It is the rule.
Symbol
With the arrival of the Symbol type in JS, we have both a new type and new magic methods. If you used to answer the question “How many types are there in JS?
JavaScript defines 6 data types:
- 5 data types primitives: Number, String, Boolean and two special types Undefined and Null (with typeof null = “object”)
- And type Object.
In 2022, this answer is already wrong and should be answered as follows: the current standard ECMAScript6+ defines 7 data types: all of the above plus the Symbol type.
Symbol is a new primitive data type that is used to create unique identifiers. The symbol is obtained with the Symbol function:
let symbol = Symbol();
typeof symbol === 'symbol'
- If it’s a primitive, it can also be returned from toString and valueOf
, hmm?
- Oh, interesting question!
const obj = {
toString() {
console.log('Call toString')
return Symbol.for("lol")
}
}+obj // TypeError: Cannot convert a Symbol value to a number
''+obj // TypeError: Cannot convert a Symbol value to a string
Hmm. That breaks the logic. And you said… Ha ha, JavaScript is buggy!
Just because it doesn’t convert, doesn’t mean it can’t be returned. Proof:
obj == Symbol.for("lol") // true
It’s the same with valueOf
and everything works according to the rules.
Well-known Symbols
There is also a global symbol registry, which allows you to have common global symbols, which can be retrieved from the registry by name. To read (or create, in the absence of) a global symbol, call the Symbol.for(name) method.
The peculiarity of symbols is that if a symbol property is written to an object, it does not participate in iterations:
const obj = {
foo: 123,
bar: 'abc',
[Symbol.for('obj.wow')]: true
}Object.keys(obj) // foo, barobj[Symbol.for('obj.wow')] // true
Symbols are actively used within the ES standard itself. There are many system symbols, and they are listed in the specification, in the table Well-known Symbols. Symbols are commonly referred to as “@@name
” for short, and they are available as properties of theSymbol object.
Many people are already familiar withSymbol.iterator
For example, we want it like this:
const obj = { foo: 123, bar: 'abc' }
for (let v of obj) console.log(v)
// TypeError: obj is not iterable
But we can’t. But if we want to, we can:
const obj = {
foo: 123,
bar: 'abc',
[Symbol.iterator]() {
const values = Object.values(this)
return { // Iterator object
next: ()=>({
done : 0 === values.length,
value: values.pop(),
})
}
}
}for (let v of obj) console.log(v)
Unobvious? Then we can create a class:
class IObject {
constructor(obj) {
for (let k in obj) {
if (!obj.hasOwnProperty(k)) continue
this[k] = obj[k]
}
} [Symbol.iterator]() {
const values = Object.values(this);
return { // Iterator interface
next: ()=>({
done : 0 === values.length,
value: values.pop(),
})
}
}
}const obj = new IObject({
foo: 123 ,
bar: 'abc',
});for (let v of obj) console.log(v)
We got a little distracted, we’re not talking about iterators…
Well, in this table we see two symbols:
@@toPrimitive.
@@toStringTag
Wow, what is this wow? Let’s figure it out…
Symbol.toPrimitive
This is a new “magic” method designed to replace toString and valueOf. Its fundamental difference: it takes an argument — the type to which it is desired to lead:
const obj = {
[Symbol.toPrimitive](hint) {
return {
number: 100,
string: 'abc',
default: true
}[hint]
}
}console.log( +obj ); // 100
console.log( obj + 1 ); // 2
console.log( 1 - obj ); // -99
console.log( `${obj}` ); // abc
console.log( obj + '1' ); // true1
console.log( 'a' + obj ); // atrue
There are three types of hinting available:
"number"
"string"
"default"
If the @@toPrimitive
method is implemented, then toString
and valueOf
are not called.
More precisely, valueOf
is an alias to @@toPrimitive
, and an explicit call will pass hinting type default:
console.log( obj.valueOf() ); // true
But!
const obj = {
toString() { console.log('toString'); return 1 },
valueOf() { console.log('valueOf'); return 1 },
[Symbol.toPrimitive](hint) {
return {
number: 100,
string: 'abc',
default: true
}[hint]
}
}
If toString and valueOf
methods are not explicitly called, they will not be called. If you call them explicitly, they will work explicitly as normal methods.
If you try to return non-primitive, there will be an error of this kind:
TypeError: Cannot convert object to primitive value
And now about the task I asked above. Is it possible to implement behavior like in Date object? Answer: yes, it is possible, thanks to the new @@toPrimitive
:
We will get two different conclusions:
1.36
1 usd = 1.36 canadian dollar on date 10/28/2022, 1:45:26 PM
So it turns out that what used to be considered an exception can be considered normal behavior today, implemented through @@toPrimitive
, which we can also replicate.
Symbol.toStringTag
This is a symbol that contains a string, which is the tag that is output when toString is called by default:
You can override this property for your classes, so that when you debug, the output is more informative but still standardized:
Symbol.toStringTag
always returns a string. If not a string, it will be ignored and default Symbol.toStringTag from parent will be triggered. If there is toString, valueOf
or Symbol.toPrimitive
— their priority is higher, so the default call that uses this tag will not be triggered.
@@toStringTag
will always be prefixed with [object]
.
Node.js and inspect
We could end here, but we still need to say a few more words about magic in the context of Node.js (browsers don’t have it).
Until recently, such code could blow your mind, again due to ignorance and peculiarities of Node.js API already. This is a reserved method in Node.js. Was reserved, but not anymore. If you were declaring an inspect method, you might have been surprised by what was dumped to the console.
But now it’s ok, we have Node.js 18+ and it no longer calls the inspect method. But! But with the arrival of symbols, an alternative magic has appeared. Thanks to symbols, you can extend the API without fear of breaking the user code, so there are a number of symbols that are specific only to the node, among them:
Yes yes, that’s right, the console displays 42.
Different npm packages use this mechanism, so if you want to see the real state of things, use console.dir or:
Generally, some people recommend using util.inspect for debugging instead of console.log
and/or console.dir
, but this is debatable.
Also util.inspect.custom takes arguments (like console.dir
) and you can internally implement a smart inspect that responds to settings:
[util.inspect.custom](depth, options) {
/*
depth: 2
options: {
budget: {},
indentationLvl: 0,
seen: [],
stylize: [Function: stylizeWithColor],
showHidden: false,
depth: 2,
colors: true,
customInspect: true,
showProxy: false,
maxArrayLength: 100,
breakLength: 60,
compact: true,
sorted: false,
getters: false }
*/
}
The END
Ugh, I think that’s it. What’s all this for?
If about the practical sense: understanding how your tool works, you can make fewer errors (and catch them faster) even without using TypeScript and other tools (which sometimes create the illusion of safety).
Yes yes, I believe that unnecessary transpilers and wrappers over language are additional points of failure that can lead to problems. If you can easily debug vanilla code, the result of transpilers and other postprocessing can be unpredictable and contain errors (this has already happened in practice more than once). But this is another topic for debate.