Symbols are primitive values that are created via the factory function Symbol()
:
const mySymbol = Symbol('mySymbol');
The parameter is optional and provides a description, which is mainly useful for debugging.
Symbols are primitive values:
They have to be categorized via typeof
:
const sym = Symbol();
assert.equal(typeof sym, 'symbol');
They can be property keys in objects:
const obj = {
[sym]: 123,
};
Even though symbols are primitives, they are also like objects in that each value created by Symbol()
is unique and not compared by value:
> Symbol() === Symbol()
false
Prior to symbols, objects were the best choice if we needed values that were unique (only equal to themselves):
const string1 = 'abc';
const string2 = 'abc';
assert.equal(
string1 === string2, true); // not unique
const object1 = {};
const object2 = {};
assert.equal(
object1 === object2, false); // unique
const symbol1 = Symbol();
const symbol2 = Symbol();
assert.equal(
symbol1 === symbol2, false); // unique
The parameter we pass to the symbol factory function provides a description for the created symbol:
const mySymbol = Symbol('mySymbol');
The description can be accessed in two ways.
First, it is part of the string returned by .toString()
:
assert.equal(mySymbol.toString(), 'Symbol(mySymbol)');
Second, since ES2019, we can retrieve the description via the property .description
:
assert.equal(mySymbol.description, 'mySymbol');
The main use cases for symbols, are:
Let’s assume you want to create constants representing the colors red, orange, yellow, green, blue, and violet. One simple way of doing so would be to use strings:
const COLOR_BLUE = 'Blue';
On the plus side, logging that constant produces helpful output. On the minus side, there is a risk of mistaking an unrelated value for a color because two strings with the same content are considered equal:
const MOOD_BLUE = 'Blue';
assert.equal(COLOR_BLUE, MOOD_BLUE);
We can fix that problem via symbols:
const COLOR_BLUE = Symbol('Blue');
const MOOD_BLUE = Symbol('Blue');
assert.notEqual(COLOR_BLUE, MOOD_BLUE);
Let’s use symbol-valued constants to implement a function:
const COLOR_RED = Symbol('Red');
const COLOR_ORANGE = Symbol('Orange');
const COLOR_YELLOW = Symbol('Yellow');
const COLOR_GREEN = Symbol('Green');
const COLOR_BLUE = Symbol('Blue');
const COLOR_VIOLET = Symbol('Violet');
function getComplement(color) {
switch (color) {
case COLOR_RED:
return COLOR_GREEN;
case COLOR_ORANGE:
return COLOR_BLUE;
case COLOR_YELLOW:
return COLOR_VIOLET;
case COLOR_GREEN:
return COLOR_RED;
case COLOR_BLUE:
return COLOR_ORANGE;
case COLOR_VIOLET:
return COLOR_YELLOW;
default:
throw new Exception('Unknown color: '+color);
}
}
assert.equal(getComplement(COLOR_YELLOW), COLOR_VIOLET);
The keys of properties (fields) in objects are used at two levels:
The program operates at a base level. The keys at that level reflect the problem domain – the area in which a program solves a problem – for example:
ECMAScript and many libraries operate at a meta-level. They manage data and provide services that are not part of the problem domain – for example:
The standard method .toString()
is used by ECMAScript when creating a string representation of an object (line A):
const point = {
x: 7,
y: 4,
toString() {
return `(${this.x}, ${this.y})`;
},
};
assert.equal(
String(point), '(7, 4)'); // (A)
.x
and .y
are base-level properties – they are used to solve the problem of computing with points. .toString()
is a meta-level property – it doesn’t have anything to do with the problem domain.
The standard ECMAScript method .toJSON()
const point = {
x: 7,
y: 4,
toJSON() {
return [this.x, this.y];
},
};
assert.equal(
JSON.stringify(point), '[7,4]');
.x
and .y
are base-level properties, .toJSON()
is a meta-level property.
The base level and the meta-level of a program must be independent: Base-level property keys should not be in conflict with meta-level property keys.
If we use names (strings) as property keys, we are facing two challenges:
When a language is first created, it can use any meta-level names it wants. Base-level code is forced to avoid those names. Later, however, when much base-level code already exists, meta-level names can’t be chosen freely, anymore.
We could introduce naming rules to separate base level and meta-level. For example, Python brackets meta-level names with two underscores: __init__
, __iter__
, __hash__
, etc. However, the meta-level names of the language and the meta-level names of libraries would still exist in the same namespace and can clash.
These are two examples of where the latter was an issue for JavaScript:
In May 2018, the Array method .flatten()
had to be renamed to .flat()
because the former name was already used by libraries (source).
In November 2020, the Array method .item()
had to be renamed to .at()
because the former name was already used by library (source).
Symbols, used as property keys, help us here: Each symbol is unique and a symbol key never clashes with any other string or symbol key.
As an example, let’s assume we are writing a library that treats objects differently if they implement a special method. This is what defining a property key for such a method and implementing it for an object would look like:
const specialMethod = Symbol('specialMethod');
const obj = {
_id: 'kf12oi',
[specialMethod]() { // (A)
return this._id;
}
};
assert.equal(obj[specialMethod](), 'kf12oi');
The square brackets in line A enable us to specify that the method must have the key specialMethod
. More details are explained in “Computed keys in object literals” (§30.7.2).
Symbols that play special roles within ECMAScript are called publicly known symbols. Examples include:
Symbol.iterator
: makes an object iterable. It’s the key of a method that returns an iterator. For more information on this topic, see “Synchronous iteration” (§32).
Symbol.hasInstance
: customizes how instanceof
works. If an object implements a method with that key, it can be used at the right-hand side of that operator. For example:
const PrimitiveNull = {
[Symbol.hasInstance](x) {
return x === null;
}
};
assert.equal(null instanceof PrimitiveNull, true);
Symbol.toStringTag
: influences the default .toString()
method.
> String({})
'[object Object]'
> String({ [Symbol.toStringTag]: 'is no money' })
'[object is no money]'
Note: It’s usually better to override .toString()
.
Exercises: Publicly known symbols
Symbol.toStringTag
: exercises/symbols/to_string_tag_test.mjs
Symbol.hasInstance
: exercises/symbols/has_instance_test.mjs
What happens if we convert a symbol sym
to another primitive type? Table 24.1 has the answers.
Convert to | Explicit conversion | Coercion (implicit conv.) |
---|---|---|
boolean | Boolean(sym) → OK | !sym → OK |
number | Number(sym) → TypeError | sym*2 → TypeError |
string | String(sym) → OK | ''+sym → TypeError |
sym.toString() → OK | `${sym}` → TypeError |
Table 24.1: The results of converting symbols to other primitive types.
One key pitfall with symbols is how often exceptions are thrown when converting them to something else. What is the thinking behind that? First, conversion to number never makes sense and should be warned about. Second, converting a symbol to a string is indeed useful for diagnostic output. But it also makes sense to warn about accidentally turning a symbol into a string (which is a different kind of property key):
const obj = {};
const sym = Symbol();
assert.throws(
() => { obj['__'+sym+'__'] = true },
{ message: 'Cannot convert a Symbol value to a string' });
The downside is that the exceptions make working with symbols more complicated. You have to explicitly convert symbols when assembling strings via the plus operator:
> const mySymbol = Symbol('mySymbol');
> 'Symbol I used: ' + mySymbol
TypeError: Cannot convert a Symbol value to a string
> 'Symbol I used: ' + String(mySymbol)
'Symbol I used: Symbol(mySymbol)'