Cover image by Alexander Fradellafra from Pixabay
Symbols are a less known primitive data type among string, number, bigint, boolean and undefined
of Javascript. They were added as part of ES6 specification which was a big facelifting of Javascript language and included a lot of new features.
Why do we need Symbols?
Symbols have 2 main use cases:
Create hidden properties on objects that no other code (that has no reference to the symbol used) can access or overwrite. The convention of most built-in functions and libraries is to avoid referencing symbols declared on an object if there is no direct need to change them.
System symbols that are used to change default behaviors of object - for example,
Symbol.toPrimitive
that is used to define object behavior during the conversion of an object to primitive orSymbol.iterator
that is used to set object behavior during the iteration.
Symbols basics
Symbols' syntax is very symbol simple. We can create a new symbol by writing:
// mySymbol is a new created symbol
let mySymbol = Symbol();
console.log(mySymbol) // Symbol()
Symbol() function has an optional description field and can be used in this way:
// mySymbol is a new created symbol that now has a description
let mySymbol = Symbol('decription of my symbol');
console.log(mySymbol) // Symbol(decription of my symbol)
The description field is just a text that will be attached to the symbol - it's mostly used for debugging purposes.
Every symbol returned from Symbol() function is unique, meaning that 2 symbols created using the function will never be equal (even they have same description passed to the function):
let firstSymbol = Symbol("sameDescription");
let secondSymbol = Symbol("sameDescription");
console.log(firstSymbol == secondSymbol); //false
Creating hidden properties in object
Now when we know how to create a new Symbol let's see how can use it to create a hidden property of an object.
First of all - why would we do that?
As a common use case, I can mention an example when our code is used by some third party. For example - we are writing an open-source library or a library that is going to be used by other teams of developers in our organization. We may want to add some "under-the-hood" properties to objects to be able to access them in our code - but at the same time, we want to guarantee that no other code will be able to access these properties.
If we were using regular object properties declared by a string - the developers using our library can do that accidentally by iterating over object keys or creating a property with the same name and overwriting it.
Symbols are here to help us.
For example - let's say we have an object representing a rock star:
let rockStar = {
name: "James Hetfield",
band: "Metallica",
role: "Voice & Rythm guitar"
}
Now we want to add a hidden property that will represent an internal id that we want to be exposed only in our code and avoid using it outside our internal code:
let idSymbol = Symbol('id symbol used in rockStar object');
let rockStar = {
name: "James Hetfield",
band: "Metallica",
role: "Voice & Rythm guitar"
[idSymbol]: "this-id-property-is-set-by-symbol"
}
If we now want to access / change / delete the property set using the Symbol - we need to have the reference to the Symbol that was used to declare it. Without having it - we can't do that.
Also - when iterating over the keys of an object - we will not get a reference to a property set using the Symbol:
console.log(Object.keys(rockStar)); // (3) ["name", "band", "role"]
for ... in ...
loop will also ignore our symbol:
for (key in rockStar) {
console.log(key);
}
// output:
// name
// band
// role
Global symbol registry
What if in some cases we do want to add an ability to give access to properties that were defined using symbols? What if we need to share access to these properties between different modules of our application?
This is where Global symbol registry comes to help us. Think of it as a dictionary placed on a global level - accessible everywhere in our code where we can set or get Symbols by a specific key.
Symbol.for
is a syntax used to get Symbols from the global registry.
Let's take the same example and re-write it using the global registry:
let idSymbol = Symbol.for('rockStarIdSymbol');
let rockStar = {
name: "James Hetfield",
band: "Metallica",
role: "Voice & Rythm guitar"
[idSymbol]: "this-id-property-is-set-by-symbol"
}
let idSymbol = Symbol.for('rockStarIdSymbol');
will do the following:
- Check if the global registry has a symbol related to the key that equals
rockStarIdSymbol
and if there is one - return it - If not - create a new symbol, store it in the registry and return it.
This means, that if we will need to access our property in any other place in the code we can do the following:
let newSymbol = Symbol.for('rockStarIdSymbol');
console.log(rockStar[newSymbol]); // "this-id-property-is-set-by-symbol"
As a result - worth mentioning that 2 different Symbols returned by the same key in the global registry will be equal:
let symbol1 = Symbol.for('rockStarIdSymbol');
let symbol2 = Symbol.for('rockStarIdSymbol');
console.log(symbol1 === symbol2); // true
There is also a way to check which key Symbol is related to in the global registry using Symbol.keyFor
function.
const symbolForRockstar = Symbol.for('rockStarIdSymbol')
console.log(Symbol.keyFor(symbolForRockstar)); //rockStarIdSymbol
Symbol.keyFor
is checking the global registry and finds the key for the symbol. If the symbol is not registered in the registry - undefined
will be returned.
System symbols
System symbols are symbols that can be used to customize the behavior of objects. The full list of system symbols can be found in latest language specification. Each system symbol gives access to some specification which behavior we can overwrite and customize.
As an example - let's see a usage one of the commonly used symbols - Symbol.iterator
that gives us access to the iterator
specification.
Let's assume we want to write a Javascript class representing a music band.
It will probably have a band's name, style, and a list of band members.
class Band {
constructor(name, style, members) {
this.name = name;
this.style = style;
this.members = members;
}
}
And we will be able to create a new instance of the class by writing something like this:
const metallicaBand = new Band('Metallica', 'Heavy metal',
['James', 'Lars', 'Kirk', 'Robert'];
What if we'll want our users to be able to iterate of the instance of the class like it was an array and get the names of band members? This behavior is reused in a few libraries having arrays wrapped inside objects.
Right now - if we will try to iterate over our object using a for ... of
loop - we will get an error saying Uncaught TypeError: "metallicaBand" is not iterable
. That's because our class definition has no instruction on how this iteration should be done. If we do want to enable iteration over it - we need to set the behavior and Symbol.iterator is a system symbol that we should use.
Let's add it to our class definition:
class Band {
constructor(name, style, members) {
this.name = name;
this.style = style;
this.members = members;
}
[Symbol.iterator]() {
return new BandIterator(this);
}
}
class BandIterator{
// iterator implementation
}
I will not dive into the actual implementation of the iterator - this can be a good topic for a separate post. But talking of Symbols - that's the use case we should know. Almost every native behavior can be changed and system symbols are the way to do it in javascript classes.
What else?
1) Well, technically properties on objects that are set using symbols are not 100% hidden. There are methods Object.getOwnPropertySymbols(obj)
, that returns all symbols set on an object and Reflect.ownKeys(obj)
that lists all properties of on object, including symbols. But the common convention is not to use these methods for listing, iteration, and any other generic actions performed on objects.
2) Few times I saw code that had symbols used to declare enum values, like:
const ColorEnum = Object.freeze({
RED: Symbol("RED"),
BLUE: Symbol("BLUE")
});
Not sure how good this practice is. Assuming that Symbols are not serializable and every attempt to stringify these values will just remove them from the object.
When using symbols - use serialization carefully. And overall - avoid making deep copies using JSON.parse(JSON.stringify(...))
. This approach sometimes can cause hard to catch bugs that are causing sleepless nights!
3) Function used for shallow object cloning - Object.assign
copies both symbols and regular string properties. This sounds like a proper design behavior.
I think that's all you need to know about symbols to have the full picture. Did I forget anything?
Happy you made it until this point!
Thanks for reading, as usual, I will appreciate any feedback.
If you love Javascript as I do - visit https://watcherapp.online/ - my side project having all javascript blog posts in one place, there is a ton of interesting stuff!
Top comments (0)