Symbol Basics β Always Unique
Call Symbol(description) to create a new unique symbol. The optional description string is for debugging only β it is not part of the symbol's identity.
const s1 = Symbol('mySymbol');
const s2 = Symbol('mySymbol');
console.log(s1 === s2); // false β different symbols!
console.log(typeof s1); // 'symbol'
console.log(s1.description); // 'mySymbol'
console.log(s1.toString()); // 'Symbol(mySymbol)'
// Cannot coerce to string with + (throws TypeError)
try {
const str = 'prefix:' + s1; // TypeError
} catch (e) {
console.error(e.message);
}
// Use String() or .toString() explicitly
console.log(String(s1)); // 'Symbol(mySymbol)'
console.log(`${s1.description}`); // 'mySymbol' (access description, not coerce)
false symbol mySymbol Symbol(mySymbol) Cannot convert a Symbol value to a string Symbol(mySymbol) mySymbol
Symbols as Object Property Keys
Symbol-keyed properties do not show up in Object.keys(), for...in, or JSON.stringify(). This makes them ideal for adding metadata or extension points to objects without polluting their enumerable property list.
const ID = Symbol('id');
const VERSION = Symbol('version');
const user = {
name: 'Alice',
email: 'alice@example.com',
[ID]: 42, // symbol key β not enumerable via Object.keys
[VERSION]: '2.1.0'
};
// String keys β visible
console.log(Object.keys(user)); // ['name', 'email']
console.log(JSON.stringify(user)); // {"name":"Alice","email":"alice@example.com"}
// Symbol keys β hidden from enumeration but directly accessible
console.log(user[ID]); // 42
console.log(user[VERSION]); // '2.1.0'
// Retrieve symbol keys explicitly
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id), Symbol(version)]
// Reflect.ownKeys gets ALL keys (string + symbol)
console.log(Reflect.ownKeys(user)); // ['name', 'email', Symbol(id), Symbol(version)]
['name', 'email']
{"name":"Alice","email":"alice@example.com"}
42
2.1.0
[Symbol(id), Symbol(version)]
['name', 'email', Symbol(id), Symbol(version)]
Symbol.for() β The Global Registry
Symbol.for(key) creates (or retrieves) a symbol from a global, cross-realm registry. Two calls with the same key return the exact same symbol, making it useful for sharing symbols across modules or iframes.
// Symbol.for() uses a global registry
const a = Symbol.for('app.theme');
const b = Symbol.for('app.theme');
console.log(a === b); // true β same registry entry!
// Vs Symbol() β always unique
const c = Symbol('app.theme');
const d = Symbol('app.theme');
console.log(c === d); // false
// Look up the registry key for a Symbol.for symbol
console.log(Symbol.keyFor(a)); // 'app.theme'
console.log(Symbol.keyFor(c)); // undefined β not in registry
// Practical: sharing a protocol key across modules
const DISPOSABLE = Symbol.for('Symbol.disposable'); // shared protocol key
class Connection {
[DISPOSABLE]() { console.log('Connection closed'); }
}
const conn = new Connection();
conn[Symbol.for('Symbol.disposable')]();
true false app.theme undefined Connection closed
Well-Known Symbols
JavaScript defines a set of built-in symbols on the Symbol object that serve as hooks into language-level behaviour. Implementing these on your class changes how JavaScript treats your objects.
| Well-Known Symbol | Hook | Used by |
|---|---|---|
Symbol.iterator | Makes object iterable | for...of, spread, destructuring |
Symbol.toPrimitive | Custom type coercion | +, -, template literals, comparisons |
Symbol.hasInstance | Customise instanceof | instanceof operator |
Symbol.toStringTag | Custom [object X] tag | Object.prototype.toString.call(obj) |
Symbol.species | Constructor for derived objects | map(), filter(), slice() on subclasses |
Symbol.asyncIterator | Makes object async iterable | for await...of |
Custom Symbol.iterator
class NumberRange {
constructor(from, to) {
this.from = from;
this.to = to;
}
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
}
const range = new NumberRange(1, 5);
console.log([...range]); // [1, 2, 3, 4, 5]
for (const n of range) process.stdout.write(n + ' ');
console.log();
const [first, second] = range; // destructuring
console.log(first, second);
[1, 2, 3, 4, 5] 1 2 3 4 5 1 2
Symbol.toPrimitive and Symbol.toStringTag
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
// Called when JS needs a primitive from this object
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount;
if (hint === 'string') return `${this.amount} ${this.currency}`;
// default hint (e.g., ==, +)
return this.amount;
}
// Changes [object Object] in toString output
get [Symbol.toStringTag]() {
return 'Money';
}
}
const price = new Money(29.99, 'USD');
console.log(+price); // 29.99 (number hint)
console.log(`${price}`); // '29.99 USD' (string hint)
console.log(price + 10); // 39.99 (default hint)
console.log(Object.prototype.toString.call(price)); // [object Money]
// Symbol.hasInstance: customise instanceof
class EvenNumber {
static [Symbol.hasInstance](value) {
return Number.isInteger(value) && value % 2 === 0;
}
}
console.log(4 instanceof EvenNumber); // true
console.log(7 instanceof EvenNumber); // false
console.log(0 instanceof EvenNumber); // true
29.99 29.99 USD 39.99 [object Money] true false true
The hint parameter is 'number' (numeric context: unary +, arithmetic), 'string' (string context: template literal, String()), or 'default' (ambiguous: + with mixed operands, loose equality ==). Always handle all three cases for predictable coercion.
Symbol-keyed properties are not private β anyone with a reference to the Symbol can access the property. Object.getOwnPropertySymbols() also exposes them. For true privacy, use private class fields (#field). Symbols are best used for avoiding name collisions, not for data hiding.
ποΈ Practical Exercise
Create a LinkedList class that implements Symbol.iterator to make instances iterable. The iterator should traverse nodes from head to tail. Test with for...of, spread ([...list]), and destructuring. Add a Symbol.toStringTag getter so Object.prototype.toString.call(list) returns '[object LinkedList]'.
π₯ Challenge Exercise
Implement a Unit class (for physical units like metres, seconds, kilograms) that uses Symbol.toPrimitive for numeric and string coercions, Symbol.toStringTag for a custom tag, and a static Symbol.hasInstance that returns true for any object with a value and unit property. Demonstrate arithmetic between two units of the same type and error handling for incompatible units.
Interview Questions
- What is a Symbol and why is every Symbol unique?
- What is the difference between Symbol() and Symbol.for()?
- Why are Symbol-keyed properties hidden from Object.keys() and JSON.stringify()?
- What are well-known symbols? Name three and explain what each does.
- Are Symbol-keyed properties truly private? How do you access them?
- When would you use Symbol.for() instead of Symbol()?
π Summary
- Every
Symbol()call produces a unique primitive value β even with the same description. Symbol.for(key)uses a global registry; same key always returns the same symbol.- Symbol keys are non-enumerable: hidden from
Object.keys(),for...in, andJSON.stringify(). - Retrieve symbol keys with
Object.getOwnPropertySymbols()orReflect.ownKeys(). - Well-known symbols hook into language behaviour:
Symbol.iterator,Symbol.toPrimitive,Symbol.hasInstance,Symbol.toStringTag. - Symbols prevent property name collisions but are not a privacy mechanism.
Frequently Asked Questions
Not implicitly β concatenating a Symbol with a string using + throws a TypeError. You must explicitly call String(sym) or sym.toString(). This strict behaviour prevents accidental key confusion when symbols are mixed with string keys.
No. JSON.stringify skips all Symbol-keyed properties, so they are lost in serialisation. This makes Symbols useful for metadata that should only live in memory β runtime IDs, framework internals, debug tags β and not be persisted to JSON.
Symbol.species lets you control what constructor methods like map(), filter(), and slice() use to create derived objects when called on a subclass. For example, if you subclass Array and don't want filter() to return instances of your subclass (to avoid carrying over extra state), override static get [Symbol.species]() { return Array; }.