Difficulty: Advanced
Symbols are a primitive data type introduced in ES6 that represent unique, immutable identifiers. Unlike strings, every Symbol() call creates a completely unique value, even if given the same description. This uniqueness makes symbols perfect for property keys that will never collide with other keys, including those from third-party code or future language additions.
The primary purpose of symbols is to create property keys that are guaranteed to be unique. When you add a symbol-keyed property to an object, it won't interfere with any string-keyed properties. Symbol properties don't appear in for...in loops, Object.keys(), or JSON.stringify(), making them semi-private. However, they're not truly private - Object.getOwnPropertySymbols() and Reflect.ownKeys() can still access them.
Symbol.for() creates symbols in a global symbol registry. Unlike Symbol(), calling Symbol.for('key') with the same key always returns the same symbol, enabling cross-realm symbol sharing. This is useful when multiple parts of your application (or different iframes) need to reference the same symbol. Symbol.keyFor() retrieves the key for a globally registered symbol.
Well-known symbols are built-in symbol constants that customize core language behavior. Symbol.iterator defines the default iterator for an object (used by for...of, spread, and destructuring). Symbol.toPrimitive controls how an object is converted to a primitive value. Symbol.hasInstance customizes instanceof behavior. These well-known symbols are JavaScript's metaprogramming hooks.
The iterator protocol defines a standard way for objects to produce a sequence of values. An iterator is any object with a next() method that returns { value, done } objects. The value property contains the current element, and done is a boolean indicating whether the sequence is complete. When done is true, the value is typically undefined. Built-in iterables like arrays, strings, Maps, and Sets all implement this protocol.
Making custom objects iterable requires implementing the Symbol.iterator method, which must return an iterator object. Once an object is iterable, it works with for...of loops, the spread operator, destructuring assignment, Array.from(), Promise.all(), and any other construct that expects an iterable. This protocol creates a unified interface for sequential data access, regardless of the underlying data structure.
// Every symbol is unique
const sym1 = Symbol('id');
const sym2 = Symbol('id');
console.log(sym1 === sym2);
console.log(sym1.toString());
console.log(sym1.description);
// Using symbols as object keys
const ID = Symbol('id');
const user = {
name: 'Alice',
[ID]: 12345
};
console.log(user[ID]);
console.log(user.name);
// Symbol properties are hidden from normal enumeration
console.log(Object.keys(user));
console.log(JSON.stringify(user));
// But accessible via specific methods
console.log(Object.getOwnPropertySymbols(user));
console.log(Reflect.ownKeys(user));
// Global symbol registry
const globalSym1 = Symbol.for('app.id');
const globalSym2 = Symbol.for('app.id');
console.log(globalSym1 === globalSym2);
console.log(Symbol.keyFor(globalSym1));
Two Symbol('id') calls create different symbols despite the same description. Symbol-keyed properties are invisible to Object.keys() and JSON.stringify() but accessible via getOwnPropertySymbols(). Symbol.for() uses a global registry where the same key always returns the same symbol.
// Symbol.toPrimitive - customize type coercion
class Currency {
constructor(amount, code) {
this.amount = amount;
this.code = code;
}
[Symbol.toPrimitive](hint) {
switch (hint) {
case 'number': return this.amount;
case 'string': return `${this.amount} ${this.code}`;
default: return this.amount; // 'default' hint
}
}
}
const price = new Currency(49.99, 'USD');
console.log(`Price: ${price}`); // string hint
console.log(price + 10); // default hint
console.log(+price); // number hint
console.log(price > 40); // number hint
// Symbol.hasInstance - customize instanceof
class EvenNumber {
static [Symbol.hasInstance](num) {
return typeof num === 'number' && num % 2 === 0;
}
}
console.log(4 instanceof EvenNumber);
console.log(5 instanceof EvenNumber);
console.log('hello' instanceof EvenNumber);
Symbol.toPrimitive receives a hint ('string', 'number', or 'default') indicating what type the engine expects. Symbol.hasInstance lets a class define custom instanceof behavior - here EvenNumber matches any even number.
// Manual iterator usage
const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
// Custom iterable: Range
class Range {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
const step = this.step;
return {
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { value: undefined, done: true };
}
};
}
}
const range = new Range(1, 5);
for (const num of range) {
process.stdout.write(num + ' ');
}
console.log();
// Works with spread and destructuring
console.log([...new Range(0, 10, 2)]);
const [first, second, third] = new Range(100, 110, 5);
console.log(first, second, third);
The iterator protocol requires a next() method returning {value, done}. Implementing Symbol.iterator makes objects work with for...of, spread, destructuring, and Array.from(). The Range class demonstrates creating a reusable iterable with configurable start, end, and step.
class Node {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
class LinkedList {
constructor() {
this.head = null;
this.size = 0;
}
push(value) {
this.head = new Node(value, this.head);
this.size++;
return this;
}
[Symbol.iterator]() {
let current = this.head;
return {
next() {
if (current) {
const value = current.value;
current = current.next;
return { value, done: false };
}
return { value: undefined, done: true };
}
};
}
}
const list = new LinkedList();
list.push('C').push('B').push('A');
// for...of works
for (const item of list) {
process.stdout.write(item + ' -> ');
}
console.log('null');
// Spread works
console.log('Array:', [...list]);
// Destructuring works
const [head, ...rest] = list;
console.log('Head:', head);
console.log('Rest:', rest);
By implementing Symbol.iterator, the LinkedList becomes a first-class iterable. It works seamlessly with for...of, spread, and destructuring. The iterator walks through nodes following the next pointers until it reaches null.
Symbol, Symbol.for, Symbol.iterator, Symbol.toPrimitive, iterator protocol, custom iterables, for...of