A deep dive guide to JavaScript Symbols (ES-6 and beyond)

; Date: August 29, 2019

Tags: Node.JS »»»» JavaScript

ES2015 added a ton of useful new features, and among them is the Symbol type. Symbol lets us avoid property name collisions in a way where we can almost implement private properties in JavaScript objects.

In my mind the primary attraction to the Symbol type is this:

const _note_key = Symbol('key');
const _note_title = Symbol('title');
const _note_body = Symbol('body');

module.exports = class Note {
    constructor(key, title, body) {
        this[_note_key] = key;
        this[_note_title] = title;
        this[_note_body] = body;
    }
    get key() { return this[_note_key]; }
    get title() { return this[_note_title]; }
    set title(newTitle) { this[_note_title] = newTitle; }
    get body() { return this[_note_body]; }
    set body(newBody) { this[_note_body] = newBody; }   
};

We've defined a class, Note, with properties whose keys are defined using Symbol instances. We've then added get and set functions to access the properties. The goal is that the obtuseness of Symbols will make those properties private such that they cannot be accessed by other code.

As I note elsewhere JavaScript doesn't let us implement completely private properties. But it is good enough to provide a small measure of protection. See: Hiding data, creating encapsulation, in JavaScript ES-2015 classes

In the meantime it's useful to better understand JavaScript Symbol objects.

Overview of the JavaScript Symbol object

With ES6, Symbol became a new primitive type in JavaScript. A symbol is created as so:

> const sym2 = Symbol();
> console.log(sym2);
Symbol()
> const sym = Symbol('description');
> console.log(sym);
Symbol(description)

A Symbol is created by a factory function, Symbol(). You can create a bare symbol like the first instance, or give it a description string.

The key attribute of Symbol instances is that each is unique.

> Symbol('description') === Symbol('description')
false

Using Symbol instances as an object key

We already saw this in the Note class defined earlier. But let's try it with a simple example first.

> const obj = {};
> obj
{}
> const name = Symbol('name');
> const addr = Symbol('addr');
> obj[name] = "John Smith";
'John Smith'
> obj[addr] = "123 Main St";
'123 Main St'
> obj
{ [Symbol(name)]: 'John Smith', [Symbol(addr)]: '123 Main St' }
> 

The object started out empty, but now has two fields and the key to each field is the Symbol instances. The only way to access the fields is to have that exact Symbol instance.

> obj.name
undefined
> obj[name]
'John Smith'
> obj[Symbol('name')]
undefined

Remember that each Symbol instance is unique. The only way to access the field is with the correct Symbol instance. The description string passed when creating the Symbol instance is solely for your benefit.

Symbol instances are not objects

It's tempting to say Symbol object but that is incorrect.

> const name = Symbol('name');
> typeof name
'symbol'
> name instanceof Object
false

In other words, Symbol instances are not an object, because they are not an instance of the Object type. Instead they are instances of the Symbol type.

Using Symbols to identify concepts

One possible use for Symbol instances is identifying conceptual values. In many languages we are able to define global constants - e.g. in C or C++ the preprocessor lets us #define a constant that can be used throughout the application.

Suppose you wanted a symbolic way to describe some colors:

exports.COLOR_RED    = Symbol('Red');
exports.COLOR_ORANGE = Symbol('Orange');
exports.COLOR_YELLOW = Symbol('Yellow');
exports.COLOR_GREEN  = Symbol('Green');
exports.COLOR_BLUE   = Symbol('Blue');
exports.COLOR_VIOLET = Symbol('Violet');

Any code using this module could refer to the COLOR_RED concept using this export. Any code in the application could refer to COLOR_RED and know it is referring to the same instance of that symbol.

Just do not try to persist these Symbol values for use in another application. Remember that each Symbol instance is unique. Even if it were possible to persist a Symbol instance to a database, that would be useless because in another application Symbol('description') has a different value.

Using Symbols to define semi-private properties in an object

We can now return to the example at the top.

In typical JavaScript practice an object instance might have mixed-in pieces attached to the object by this or that module. By convention the public fields of an object are identified with a string key making it easy to access the field. But there is the risk that two modules attaching private properties could use the same key string, leading to a name collision. If instead these modules used Symbol instances, there is no name collision.

Using this practice we can avoid name collisions.

> const obj = {};
undefined
> obj[Symbol('foo')] = 'bar';
'bar'
> obj[Symbol('foo')] = 'bar';
'bar'
> obj
{ [Symbol(foo)]: 'bar', [Symbol(foo)]: 'bar' }

Even though the same description string was passed on each assignment, the object ended up with two fields because each Symbol instance is unique. This could be two modules attaching fields to the same object, each using Symbol('foo') as the key, but because they used a Symbol they are guaranteed to not step on each others toes.

What we do not get is full privacy. You can clearly see this object has two properties. The value of both those fields can be accessed, and the value can be changed, if you had the correct Symbol instance.

Some mechanisms to retrieve the list of object keys do not tell us about keys which are Symbol instances.

> Object.keys(obj)
[]
> Object.getOwnPropertyNames(obj)
[]
> for (let key in obj) { console.log(key); }
undefined
>
> obj['public'] = 'public value';
'public value'
> 
> obj
{ public: 'public value', [Symbol(foo)]: 'bar', [Symbol(foo)]: 'bar' }
> 
> Object.keys(obj);
[ 'public' ]

Using the same Object defined earlier, we see the keys and getOwnPropertyNames functions do not tell us about the keys that are Symbol instances. Nor does the for-in loop tell us about those keys. But as soon as we add a field where the key is a normal string, Object.keys tells us about that public field.

But we can get access to every key using the new Reflect class:

> Reflect.ownKeys(obj);
[ 'public', Symbol(foo), Symbol(foo) ]

Let's now hack our way into the private fields of the object:

> const foo1 = Reflect.ownKeys(obj)[1];
> const foo2 = Reflect.ownKeys(obj)[2];
> foo1
Symbol(foo)
> foo2
Symbol(foo)
> obj[foo1] = 'foobar';
'foobar'
> obj
{
  public: 'public value',
  [Symbol(foo)]: 'foobar',
  [Symbol(foo)]: 'bar'
}

That one field previously had the value "bar" but now has the value "foobar". We did this by going through the side door to access the field. Bottom line is, JavaScript did not give us private fields, just a way to avoid clashes in attaching fields to an object.

Summary

Symbol is a powerful new feature in JavaScript.

It seems the previous best practice for private fields was to use an obscure field name. For example Angular uses field names starting with $ to denote private fields, and strips out those fields in certain instances. But what if an Angular program needs to interface with another system that uses field names starting with $ for a different purpose?