Technology

JavaScript magic with Symbols

headerImageSource

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 behaviour of JavaScript. Learn more!

Q: How to implement object magic which has the following behavior?

const magic = {};

console.log(2 + +magic); // 42
console.log(5 + magic); // 1337
console.log(`JavaScript is ${magic}`); // "JavaScript is awesome"
console.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.

Symbols in JavaScript

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.

const mySymbol = Symbol('mySymbol');
typeof mySymbol; // "symbol"

Symbol('mySymbol') === Symbol('mySymbol'); // false

Symbols as object properties

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.

const magic = {};

function someLibFunction(obj) {
  obj.meta = 'MyLibMeta';
}

someLibFunction(magic);

console.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.

const magic = {};

function someLibFunction(obj) {
  obj.meta = 'MyLibMeta';
}

function userFunction(obj) {
  obj.meta = 'I use this for my code';
}

someLibFunction(magic);
userFunction(magic);

console.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.

const magic = {};

const libMetaSymbol = Symbol('meta');

function someLibFunction(obj) {
  obj[libMetaSymbol] = 'MyLibMeta';
}

function userFunction(obj) {
  obj.meta = 'I use this for my code';
}

someLibFunction(magic);
userFunction(magic);

console.log(magic[libMetaSymbol]); // 'MyLibMeta'
console.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().

const magic = { id: 1 };
const metaSymbol = Symbol('meta');

magic[metaSymbol] = 'MyMeta';

console.log(Object.keys(magic)); // ["id"]
console.log(Reflect.ownKeys(magic)); // ["id", [object Symbol] { ... }]
console.log(Object.getOwnPropertySymbols(magic)); // [[object Symbol] { ... }]

Well-known symbols

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.

Symbol.toPrimitive

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.

Symbol.toStringTag

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.

Answering the question

Now when we know a lot about symbols, let's see the answer to the magic object question.

const magic = {
  [Symbol.toPrimitive](hint) {
    if (hint == 'number') {
      return 40;
    }
    if (hint == 'string') {
      return 'awesome';
    }
    return 1332;
  },

  get [Symbol.toStringTag]() {
    return 'sorcery';
  },
};

console.log(2 + +magic); // 42
console.log(5 + magic); // 1337
console.log(`JavaScript is ${magic}`); // "JavaScript is awesome"
console.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.

Conclusion

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.

Share this article on

Wanna see our work?

Check out our rich portfolio and all the projects we are proud of.