JavaScript introduced symbols in ES6 as a way to prevent property name collisions. As an added bonus, symbols also provide a way to simulate private properties in 2015-2019 JavaScript.
Introduction
The simplest way to create a symbol in JavaScript is to call the Symbol()
function. The 2 key properties that makes symbols so special are:
- Symbols can be used as object keys. Only strings and symbols can be used as object keys.
- No two symbols are ever equal.
const symbol1 = Symbol();
const symbol2 = Symbol();
symbol1 === symbol2; // false
const obj = {};
obj[symbol1] = 'Hello';
obj[symbol2] = 'World';
obj[symbol1]; // 'Hello'
obj[symbol2]; // 'World'
Although the Symbol()
call makes it look like symbols are objects, symbols are
actually a primitive type in JavaScript. Using
Symbol
as a constructor with new
throws an error.
const symbol1 = Symbol();
typeof symbol1; // 'symbol'
symbol1 instanceof Object; // false
// Throws "TypeError: Symbol is not a constructor"
new Symbol();
Descriptions
The Symbol()
function takes a single parameter, the string description
.
The symbol's description
is only for debugging purposes - the description
shows up in the symbol's toString()
. However, two symbols with
the same description are not equal.
const symbol1 = Symbol('my symbol');
const symbol2 = Symbol('my symbol');
symbol1 === symbol2; // false
console.log(symbol1); // 'Symbol(my symbol)'
There is also a global symbol registry. Creating a symbol using Symbol.for()
adds a symbol to a global registry, keyed by the symbol's description
. In other
words, if you create two symbols with the same description using Symbol.for()
,
the two symbols will be equal.
const symbol1 = Symbol.for('test');
const symbol2 = Symbol.for('test');
symbol1 === symbol2; // true
console.log(symbol1); // 'Symbol(test)'
Generally speaking, you shouldn't use the global symbol registry unless you have a very good reason to, because you might run into naming collisions.
Name Collisions
The first built-in symbol in JavaScript was the Symbol.iterator
symbol in ES6.
An object that has a Symbol.iterator
function is considered an iterable.
That means you can use that object as the right hand side of a for/of
loop.
const fibonacci = {
[Symbol.iterator]: function*() {
let a = 1;
let b = 1;
let temp;
yield b;
while (true) {
temp = a;
a = a + b;
b = temp;
yield b;
}
}
};
// Prints every Fibonacci number less than 100
for (const x of fibonacci) {
if (x >= 100) {
break;
}
console.log(x);
}
Why is Symbol.iterator
a symbol rather than a string? Suppose instead of
using Symbol.iterator
, the iterable spec checked for the presence of a
string property 'iterator'
. Furthermore, suppose you had the below class
that was meant to be an iterable.
class MyClass {
constructor(obj) {
Object.assign(this, obj);
}
iterator() {
const keys = Object.keys(this);
let i = 0;
return (function*() {
if (i >= keys.length) {
return;
}
yield keys[i++];
})();
}
}
Instances of MyClass
will be iterables that allow you to iterate over the
object's keys. But the above class also has a potential flaw. Suppose a malicious
user were to pass an object with an iterator
property to MyClass
.
const obj = new MyClass({ iterator: 'not a function' });
If you were to use for/of
with obj
, JavaScript would throw TypeError: obj is not iterable
. That's because the user-specified iterator
function would overwrite the
class' iterator property. This is a similar security issue to prototype pollution, where naively copying user data may cause issues with special properties like __proto__
and constructor
.
The key pattern here is that symbols enable a clear separation between user data and program data in objects. Since symbols cannot be represented in JSON, there's no
risk of data passed into an Express API having a bad
Symbol.iterator
property. In objects that mix user data with built-in functions
and methods, like Mongoose models, you
can use symbols to ensure that user data doesn't conflict with your built-in functionality.
Private Properties
Since no two symbols are ever equal, symbols are a convenient way to simulate
private properties in JavaScript. Symbols don't show up in Object.keys()
, and
therefore, unless you explicitly export
a symbol, no other code can access
that property unless you explicitly go through the Object.getOwnPropertySymbols()
function.
function getObj() {
const symbol = Symbol('test');
const obj = {};
obj[symbol] = 'test';
return obj;
}
const obj = getObj();
Object.keys(obj); // []
// Unless you explicitly have a reference to the symbol, you can't access the
// symbol property.
obj[Symbol('test')]; // undefined
// You can still get a reference to the symbol using `getOwnPropertySymbols()`
const [symbol] = Object.getOwnPropertySymbols(obj);
obj[symbol]; // 'test'
Symbols are also convenient for private properties because they do not show
up in JSON.stringify()
output.
Specifically, JSON.stringify()
silently ignores symbol keys and values.
const symbol = Symbol('test');
const obj = { [symbol]: 'test', test: symbol };
JSON.stringify(obj); // "{}"
Moving On
Symbols are a great tool for representing internal state in objects while
ensuring that user data stays separate from program state. With symbols, there's
no more need for conventions like prefixing program state properties with '$'
. So next time you find yourself setting an object property to $$__internalFoo
, consider using a symbol instead.