Marko Marinović
Q: How to implement object magic which has the following behavior?
1const magic = {}; 2 3console.log(2 + +magic); // 42 4console.log(5 + magic); // 1337 5console.log(`JavaScript is ${magic}`); // "JavaScript is awesome" 6console.log(magic.toString()); // "[object magic]"
The question is very interesting and you are probably thinking "what kind of sorcery is this 😱?". To solve this mystery we need to learn about Symbols in JavaScript and see how they can help us in this case.
A symbol is a primitive data type introduced in ES6. It's created with Symbol function and globally unique. Symbols can be used as object properties to provide uniqueness level access to objects and as hooks into built-in operators and methods, enabling us to alter the default behavior of JavaScript.
1const mySymbol = Symbol('mySymbol'); 2typeof mySymbol; // "symbol" 3 4Symbol('mySymbol') === Symbol('mySymbol'); // false
Since symbols are globally unique, they can be used in a situation where there is a risk of property name collision. Imagine you are working on a library and need to attach your lib metadata to the supplied object.
1const magic = {}; 2 3function someLibFunction(obj) { 4 obj.meta = 'MyLibMeta'; 5} 6 7someLibFunction(magic); 8 9console.log(magic); // { meta: 'MyLibMeta' }
There is a problem with this code because the meta property could be overwritten by the user code or other library.
1const magic = {}; 2 3function someLibFunction(obj) { 4 obj.meta = 'MyLibMeta'; 5} 6 7function userFunction(obj) { 8 obj.meta = 'I use this for my code'; 9} 10 11someLibFunction(magic); 12userFunction(magic); 13 14console.log(magic); // { meta: 'I use this for my code' }
Now, userFunction has overwritten the meta property and lib is not working properly. Lib writers can use symbols for property names to avoid name collisions with other code.
1const magic = {}; 2 3const libMetaSymbol = Symbol('meta'); 4 5function someLibFunction(obj) { 6 obj[libMetaSymbol] = 'MyLibMeta'; 7} 8 9function userFunction(obj) { 10 obj.meta = 'I use this for my code'; 11} 12 13someLibFunction(magic); 14userFunction(magic); 15 16console.log(magic[libMetaSymbol]); // 'MyLibMeta' 17console.log(magic.meta); // 'I use this for my code'
Symbols as properties are not available through Object.keys, but rather through Reflect.ownKeys. This is for the purpose of backward compatibility because the old code doesn't know about symbols. Keep in mind that Reflect.ownKeys returns all property names and symbols. If you need to read only symbols, use Object.getOwnPropertySymbols().
1const magic = { id: 1 }; 2const metaSymbol = Symbol('meta'); 3 4magic[metaSymbol] = 'MyMeta'; 5 6console.log(Object.keys(magic)); // ["id"] 7console.log(Reflect.ownKeys(magic)); // ["id", [object Symbol] { ... }] 8console.log(Object.getOwnPropertySymbols(magic)); // [[object Symbol] { ... }]
Well-known symbols are defined as static properties on Symbol object. They are used by built-in JavaScript functions and statements such as toString() and for...of. toString() method uses Symbol.toStringTag and for...if uses Symbol.iterator. There are many more built-in symbols and you can read about them here.
To solve the magic object question, we need to look closer at Symbol.toPrimitive and Symbol.toStringTag symbols.
JavaScript calls the Symbol.toPrimitive method to convert an object to a primitive value. The method accepts hint as an argument, hinting at what kind of conversion should occur. hint can have a value of string, number, or default. There is no boolean hint since all objects are true in boolean context.
Property used internally by Object.prototype.toString() method. You would assume that string template literals also call Symbol.toStringTag under the hood, but that's not the case. Template literals call Symbol.toPrimitive method with a string hint.
Now when we know a lot about symbols, let's see the answer to the magic object question.
1const magic = { 2 [Symbol.toPrimitive](hint) { 3 if (hint == 'number') { 4 return 40; 5 } 6 if (hint == 'string') { 7 return 'awesome'; 8 } 9 return 1332; 10 }, 11 12 get [Symbol.toStringTag]() { 13 return 'sorcery'; 14 }, 15}; 16 17console.log(2 + +magic); // 42 18console.log(5 + magic); // 1337 19console.log(`JavaScript is ${magic}`); // "JavaScript is awesome" 20console.log(magic.toString()); // "[object sorcery]"
First console.log converts magic to a number and adds 2. Conversion to number internally calls Symbol.toPrimitive function with hint number.
Second console.log adds magic to 5. Addition internally calls Symbol.toPrimitive function with hint default.
Third console.log uses magic with string template literals. Conversion to string, in this case, calls Symbol.toPrimitive function with hint string.
Final console.log calls toString() method on magic object. toString() internaly calls Symbol.toStringTag property.
Symbols are globally unique primitive types which allow us to avoid property name collision and hook into JavaScript internals. If you want to read more about symbols, visit EcmaScript specs and Mozzila docs.