📜 ⬆️ ⬇️

Features of using the Symbol data type in JavaScript

Symbolic primitives are one of the innovations of the ES6 standard that brought some valuable features to JavaScript. Symbols represented by the Symbol data type are especially useful when used as identifiers of object properties. In connection with such a scenario of their application, the question arises of what they can do, what strings cannot.



In the material, the translation of which we publish today, we will focus on the Symbol data type in JavaScript. We begin with an overview of some of the features of JavaScript, which need to be guided in order to deal with the characters.

Preliminary Information


In JavaScript, in fact, there are two kinds of values. The first type is the primitive values, the second is the object ones (functions are also included). Primitive values ​​include simple data types like numbers (this includes everything from integers to floating-point numbers, Infinity and NaN values), logical values, strings, undefined values ​​and null values. Notice that although typeof null === 'object' turns out true when checking the type, null is a primitive value.
')
Primitive values ​​are immutable. They can not be changed. Of course, in a variable that stores a primitive value, you can write something new. For example, here the new value is written to the 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 data management is organized depends on their type. If a primitive value represented by some 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 an object value represented by a variable to the function and modify it, then what is stored in this variable changes.

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 the mysterious NaN , which is not equal to itself) always turn out to be equal to other primitive values ​​that look the same as themselves. For example:

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

However, the construction of object values ​​that look alike does not lead to the fact that entities are obtained, comparing which will reveal their equality with each other. You can check it like this:

 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 applied literally everywhere. For example, they are often used as collections of the key / value type. But before the appearance of the Symbol data type, only strings could be used as object keys. This was a serious limitation on the use of objects in the form of collections. When attempting 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 symbols, we would like to note that the Map data structure was created to allow using key / value data stores in situations where the key is not a string.

What is a character?


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

 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 character description 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 object property keys


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

 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 the keys specified by the characters are not returned when the Object.keys() method is Object.keys() . The code written before the appearance of symbols in JS knows nothing about them, as a result, information about the keys of objects represented by characters should not be returned by the ancient Object.keys() method.

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

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

 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 

Please note that work is currently under way 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. Private field support is already available in Chrome browser version 72 and later.

Preventing 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 of the language for other reasons. Namely, they are useful in situations when certain libraries need to add properties to the objects described beyond their limits, and at the same time not to be afraid of the collision of the names of the properties of the objects.

Consider an example in which two different libraries want to add metadata to an object. It is possible that both libraries need to be equipped with an object with certain identifiers. If you simply use something like the two-letter id string for the name of such a property, you might 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 symbols in our example, then each library can generate, at 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; } 

Just looking at such a script, you can feel the benefits of the appearance of characters in JavaScript.

However, there may be a question regarding the use of libraries for property names of objects, random strings or strings with a complex structure, including, for example, the name of the library. Such 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. Such approaches, in fact, are very similar to what happens when using symbols. And if, using random identifiers or namespaces, a pair of libraries does 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 to naming properties of objects are not completely equivalent. Names of properties that are randomly generated or using namespaces have a drawback: the corresponding keys are very easy to detect, especially if the code is iterating over the keys of objects or serializing 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 in this situation a symbol was used for the key name, 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 the changes have been made to the JSON specification. JSON supports, as keys of properties of objects, only strings. When the object is serialized, no attempt is made to present the characters in any particular form.

The 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 enumerable handle to false behave in much the same way as keys represented by characters. Both are not displayed when Object.keys() called, and both can be detected using 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 hidden keys do not fall into the JSON representation of the object. Both can be found by calling the Reflect.ownKeys() method. As a result, those and others can not be called truly private. If we assume that some random values ​​or library namespaces are used to generate key names, this means that we have 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 the strings are immutable, and the characters are guaranteed unique, there is always the possibility that someone, going through all possible combinations of characters in a string, will cause a name collision. From a mathematical point of view, this means that the characters really give us a valuable opportunity, which is absent in lines.

In Node.js, when examining objects (for example, using console.log() ), if an object method with the name inspect detected, then this method is used to get a string representation of the object and then display it on the screen. It is easy to understand that absolutely everyone cannot take this into account, so this behavior of the system can lead to a call to the method of an inspect object, which is intended for solving 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 implement this feature, the require('util').inspect.custom . And this means that no one will ever be able to inadvertently disrupt the operation of the system by creating an object method called inspect .

Imitation of private properties


Here is an interesting approach that can be used to simulate the private properties of objects. This approach involves the use of another modern JavaScript feature - proxy objects. Such objects serve as wrappers for other objects that allow the programmer to interfere with the actions performed on these objects.

Proxy objects offer many ways to intercept actions performed on objects. We are interested in the ability to control the operations of reading the keys of an object. In detail about the proxy objects, we will not go deep here. If you are interested in them - take a look at this publication.

We can use a proxy to control which properties of an object are visible from the outside. In this case, we want to create a proxy that hides two properties known to us. One has the string name _favColor , and the second is represented by the 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 

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

Here 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 the browser. The fact is that this feature allows you to access an object hidden behind a proxy object, if you have access to a proxy object. Here is an example demonstrating 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 this feature from being used in a particular Node.js instance, you need to either modify the global Reflect object, or bind the util process. However, this is another task. If you're interested, take a look at this JavaScript-based API security post.

Results


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

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

Source: https://habr.com/ru/post/444340/


All Articles