Features of using the Symbol data type in JavaScript

Original author: Thomas Hunter II
  • Transfer
Character primitives are one of the innovations of the ES6 standard, which brought some valuable features to JavaScript. Symbols represented by the Symbol data type are especially useful when used as identifiers for object properties. In connection with such a scenario of their application, the question arises of what they can, what the lines cannot. In the material, the translation of which we publish today, we will talk about the Symbol data type in JavaScript. We'll start by reviewing some of the JavaScript features that you need to navigate in order to deal with symbols.





Preliminary information


In JavaScript, in fact, there are two kinds of values. The first type - primitive values, the second - object (they also include functions). Primitive values ​​include simple data types like numbers (this includes everything from integers to floating-point numbers, Infinityand values NaN), logical values, strings, undefinedand values null. Please note that while checking the view it typeof null === 'object'turns trueout to nullbe a primitive value.

Primitive values ​​are immutable. They cannot be changed. Of course, you can write something new in a variable storing a primitive value. For example, here a new value is written to a variable x:

let x = 1; 
x++;

But at the same time, there is no change (mutation) of the primitive numerical value 1.

In some languages, for example, in C, there are concepts of passing arguments of functions by reference and by value. JavaScript also has something similar. How exactly the work with data is organized depends on their type. If a primitive value represented by a certain variable is passed to the function, and then it is changed in this function, the value stored in the original variable does not change. However, if you pass the object value represented by the variable to the function and modify it, then what is stored in this variable will also change.

Consider the following example:

function primitiveMutator(val) {
  val = val + 1;
}
let x = 1;
primitiveMutator(x);
console.log(x); // 1
function objectMutator(val) {
  val.prop = val.prop + 1;
}
let obj = { prop: 1 };
objectMutator(obj);
console.log(obj.prop); // 2

Primitive values ​​(with the exception of a mysterious one NaN, which is not equal to itself) always turn out to be equal to other primitive values ​​that look just like themselves. For instance:

const first = "abc" + "def";
const second = "ab" + "cd" + "ef";
console.log(first === second); // true

However, the construction of object values ​​that look the same outwardly will not lead to the fact that entities will be obtained, when compared, their equality to each other will be revealed. You can verify this by:

const obj1 = { name: "Intrinsic" };
const obj2 = { name: "Intrinsic" };
console.log(obj1 === obj2); // false
// При этом их свойства .name являются примитивными значениями:
console.log(obj1.name === obj2.name); // true

Objects play a fundamental role in JavaScript. They are used literally everywhere. For example, they are often used in the form of key / value collections. But before the appearance of the data type Symbol, only strings could be used as object keys. This was a serious limitation on the use of objects in the form of collections. When trying to assign a non-string value as an object key, this value was cast to a string. You can verify this by:

const obj = {};
obj.foo = 'foo';
obj['bar'] = 'bar';
obj[2] = 2;
obj[{}] = 'someobj';
console.log(obj);
// { '2': 2, foo: 'foo', bar: 'bar',
     '[object Object]': 'someobj' }

By the way, although this takes us a little away from the topic of characters, I want to note that the data structure Mapwas created in order to allow the use of key / value data stores in situations where the key is not a string.

What is a symbol?


Now that we have figured out the features of primitive values ​​in JavaScript, we are finally ready to start talking about characters. A symbol is a unique primitive meaning. If you approach the symbols from this position, you will notice that the symbols in this regard are similar to objects, since the creation of several instances of the symbols will lead to the creation of different values. But symbols, moreover, are immutable primitive values. Here is an example of working with characters:

const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false

When creating an instance of a character, you can use the optional first string argument. This argument is a description of the symbol that is intended for use in debugging. This value does not affect the symbol itself.

const s1 = Symbol('debug');
const str = 'debug';
const s2 = Symbol('xxyy');
console.log(s1 === str); // false
console.log(s1 === s2); // false
console.log(s1); // Symbol(debug)

Symbols as keys to property of objects


Symbols can be used as property keys for objects. It is very important. Here is an example of using them as such:

const obj = {};
const sym = Symbol();
obj[sym] = 'foo';
obj.bar = 'bar';
console.log(obj); // { bar: 'bar' }
console.log(sym in obj); // true
console.log(obj[sym]); // foo
console.log(Object.keys(obj)); // ['bar']

Note that keys specified by characters are not returned when the method is called Object.keys(). The code written before the appearance of characters in JS does not know anything about them, as a result, information about the keys of objects represented by characters should not be returned by the ancient method Object.keys().

At first glance, it may seem that the above features of characters allow you to use them to create private properties of JS objects. In many other programming languages, you can create hidden object properties using classes. The lack of this feature has long been considered one of the shortcomings of JavaScript.

Unfortunately, the code that works with objects can freely access their string keys. The code can also access keys specified by characters, moreover, even if the code from which they work with the object does not have access to the corresponding character. For example, using the method, Reflect.ownKeys()you can get a list of all the keys of an object, both those that are strings and those that are characters:

function tryToAddPrivate(o) {
  o[Symbol('Pseudo Private')] = 42;
}
const obj = { prop: 'hello' };
tryToAddPrivate(obj);
console.log(Reflect.ownKeys(obj));
        // [ 'prop', Symbol(Pseudo Private) ]
console.log(obj[Reflect.ownKeys(obj)[1]]); // 42

Note that work is currently underway to equip classes with the ability to use private properties. This feature is called Private Fields . True, it does not affect absolutely all objects, referring only to those of them that are created on the basis of previously prepared classes. Support for private fields is already available in the Chrome browser version 72 and older.

Prevent collisions of object property names


Symbols, of course, do not add to JavaScript the ability to create private properties of objects, but they are a valuable innovation in the language for other reasons. Namely, they are useful in situations when certain libraries need to add properties to objects described outside of them, and at the same time not be afraid of a collision of the names of properties of objects.

Consider an example in which two different libraries want to add metadata to an object. It is possible that both libraries need to equip the object with some identifiers. If you simply use something like a idtwo-letter string for the name of such a property , you may encounter a situation where one library overwrites the property specified by the other.

function lib1tag(obj) {
  obj.id = 42;
}
function lib2tag(obj) {
  obj.id = 369;
}

If we use the symbols in our example, then each library can generate, upon initialization, the symbols it needs. These symbols can then be used to assign properties to objects and to access these properties.

const library1property = Symbol('lib1');
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = Symbol('lib2');
function lib2tag(obj) {
  obj[library2property] = 369;
}

It is by looking at such a scenario that you can benefit from the appearance of characters in JavaScript.

However, there may be a question regarding the use of libraries for the names of properties of objects, random strings or strings with a complex structure, including, for example, the name of the library. Similar strings can form something like namespaces for identifiers used by libraries. For example, it might look like this:

const library1property = uuid(); // вызов функции для получения случайного значения
function lib1tag(obj) {
  obj[library1property] = 42;
}
const library2property = 'LIB2-NAMESPACE-id'; // использование пространства имён библиотеки
function lib2tag(obj) {
  obj[library2property] = 369;
}

In general, you can do so. Similar approaches, in fact, are very similar to what happens when using symbols. And if, using random identifiers or namespaces, a couple of libraries will not generate, by chance, the same property names, then there will be no problems with the names.

An astute reader would say now that the two approaches under consideration to naming object properties are not completely equivalent. Property names that are generated randomly or using namespaces have a drawback: the corresponding keys are very easy to find, especially if the code searches through the keys of objects or serializes them. Consider the following example:

const library2property = 'LIB2-NAMESPACE-id'; // используется пространство имён
function lib2tag(obj) {
  obj[library2property] = 369;
}
const user = {
  name: 'Thomas Hunter II',
  age: 32
};
lib2tag(user);
JSON.stringify(user);
// '{"name":"Thomas Hunter II","age":32,"LIB2-NAMESPACE-id":369}'

If a symbol were used for the key name in this situation, then the JSON representation of the object would not contain the symbol value. Why is this so? The fact is that the fact that a new data type has appeared in JavaScript does not mean that changes have been made to the JSON specification. JSON supports, as property keys, only strings. When serializing an object, no attempt is made to represent the characters in any special way.

The considered problem of getting property names into the JSON representation of objects can be solved by using Object.defineProperty():

const library2property = uuid(); // случайное значение
function lib2tag(obj) {
  Object.defineProperty(obj, library2property, {
    enumerable: false,
    value: 369
  });
}
const user = {
  name: 'Thomas Hunter II',
  age: 32
};
lib2tag(user);
// '{"name":"Thomas Hunter II",
   "age":32,"f468c902-26ed-4b2e-81d6-5775ae7eec5d":369}'
console.log(JSON.stringify(user));
console.log(user[library2property]); // 369

String keys that are “hidden” by setting their descriptorenumerable to a value falsebehave in much the same way as keys represented by characters. Both that and others are not displayed at a call Object.keys(), and that and others can be found, having taken advantage Reflect.ownKeys(). Here's what it looks like:

const obj = {};
obj[Symbol()] = 1;
Object.defineProperty(obj, 'foo', {
  enumberable: false,
  value: 2
});
console.log(Object.keys(obj)); // []
console.log(Reflect.ownKeys(obj)); // [ 'foo', Symbol() ]
console.log(JSON.stringify(obj)); // {}

Here, I must say, we almost recreated the possibilities of symbols, using other means of JS. In particular, both keys represented by symbols and private keys do not fall into the JSON representation of an object. Both can be recognized by referring to the method Reflect.ownKeys(). As a result, both of them cannot be called truly private. If we assume that some random values ​​or library namespaces are used to generate key names, then this means that we got rid of the risk of name collisions.

However, there is one small difference between using symbol names and names created using other mechanisms. Since strings are immutable, and characters are guaranteed to be unique, there is always the possibility that someone, after going through all possible combinations of characters in a string, will cause a name collision. From a mathematical point of view, this means that characters really give us a valuable opportunity that strings do not have.

In Node.js, when examining objects (for example, using console.log()), if an object method with a name is found inspect, then this method is used to obtain a string representation of the object and then display it on the screen. It is easy to understand that absolutely everyone can not take this into account, therefore, such a behavior of the system can lead to an invocation of an object methodinspect, which is designed to solve problems that are not related to the formation of a string representation of an object. This feature is deprecated in Node.js 10, in version 11 methods with a similar name are simply ignored. Now, to realize this feature, a symbol is provided require('util').inspect.custom. And this means that no one can ever inadvertently disrupt the system by creating an object method with a name inspect.

Imitation of private properties


Here's an interesting approach that you can use to simulate the private properties of objects. This approach involves the use of yet another modern JavaScript feature - proxy objects. Such objects serve as wrappers for other objects that allow the programmer to intervene in the actions performed with these objects.

Proxy objects offer many ways to intercept the actions performed on objects. We are interested in the ability to control the operation of reading keys of an object. We will not go into details about proxy objects here. If you are interested, take a look at this publication.

We can use proxies to control what properties of the object are visible from the outside. In this case, we want to create a proxy that hides two properties we know. One has a string name _favColor, and the second is represented by a character written to the variable favBook:

let proxy;
{
  const favBook = Symbol('fav book');
  const obj = {
    name: 'Thomas Hunter II',
    age: 32,
    _favColor: 'blue',
    [favBook]: 'Metro 2033',
    [Symbol('visible')]: 'foo'
  };
  const handler = {
    ownKeys: (target) => {
      const reportedKeys = [];
      const actualKeys = Reflect.ownKeys(target);
      for (const key of actualKeys) {
        if (key === favBook || key === '_favColor') {
          continue;
        }
        reportedKeys.push(key);
      }
      return reportedKeys;
    }
  };
  proxy = new Proxy(obj, handler);
}
console.log(Object.keys(proxy)); // [ 'name', 'age' ]
console.log(Reflect.ownKeys(proxy)); // [ 'name', 'age', Symbol(visible) ]
console.log(Object.getOwnPropertyNames(proxy)); // [ 'name', 'age' ]
console.log(Object.getOwnPropertySymbols(proxy)); // [Symbol(visible)]
console.log(proxy._favColor); // 'blue

Dealing with a property whose name is represented by a string _favColoris easy: just read the source code. Dynamic keys (like the uuid keys we saw above) can be matched with brute force. But without reference to the symbol, you cannot access the value Metro 2033from the object proxy.

It should be noted that in Node.js there is one feature that violates the privacy of proxy objects. This feature does not exist in the language itself, so it is not relevant for other JS runtimes, such as a browser. The fact is that this feature allows you to access the object hidden behind the proxy object, if you have access to the proxy object. Here is an example that demonstrates the ability to bypass the mechanisms shown in the previous code snippet:

const [originalObject] = process
  .binding('util')
  .getProxyDetails(proxy);
const allKeys = Reflect.ownKeys(originalObject);
console.log(allKeys[3]); // Symbol(fav book)

Now, to prevent the use of this feature in a specific instance of Node.js, you must either modify the global object Reflector the process binding util. However, this is another task. If you're interested, take a look at this post about protecting JavaScript-based APIs.

Summary


In this article, we talked about the data type Symbol, about what features it provides to JavaScript developers, and about what existing language mechanisms can be used to simulate these features.

Dear readers! Do you use symbols in your JavaScript projects?


Also popular now: