Symbols are a primitive type added in ES6. Each Symbol is guaranteed to be unique — even if two Symbols have the same description. But the real OOP power comes from well-known Symbols — special built-in Symbols that let us hook into JavaScript’s internal behavior.
What Is a Symbol?
A Symbol is a unique, immutable identifier. Unlike strings, two Symbols are never equal.
const a = Symbol("id");
const b = Symbol("id");
console.log(a === b); // false — each Symbol is unique
// We use Symbols as property keys
const user = { [a]: 123 };
console.log(user[a]); // 123
Symbols don’t show up in for...in loops or Object.keys(), making them perfect for “hidden” metadata on objects.
Why Well-Known Symbols Matter for OOP
JavaScript has a set of built-in Symbols (like Symbol.iterator, Symbol.toPrimitive) that act as hooks. When we define methods with these Symbol keys on our objects, we’re telling JavaScript how our object should behave with built-in operations like for...of, string conversion, and instanceof.
In simple language, well-known Symbols let our custom objects “speak JavaScript’s language.”
Symbol.iterator — Making Objects Iterable
When we use for...of on an array, JavaScript calls array[Symbol.iterator]() behind the scenes. We can define this on our own objects to make them work with for...of, spread syntax, and destructuring.
An iterator is an object with a next() method that returns { value, done }.
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
return current <= end
? { value: current++, done: false }
: { done: true };
}
};
}
}
const range = new Range(1, 5);
console.log([...range]); // [1, 2, 3, 4, 5]
for (const n of range) { } // works!
const [a, b] = new Range(10, 20); // a = 10, b = 11
Generators as a Shortcut
Writing iterators manually is verbose. Generators make it much cleaner. A generator function (with function*) automatically returns an iterator.
class Fibonacci {
[Symbol.iterator]() {
return this.generate();
}
*generate() {
let [a, b] = [0, 1];
while (true) {
yield a; // pauses here, returns value
[a, b] = [b, a + b];
}
}
}
const first10 = [...new Fibonacci()].slice(0, 10); // won't work — infinite!
// Instead, take manually:
const fib = new Fibonacci();
const iter = fib[Symbol.iterator]();
for (let i = 0; i < 10; i++) {
console.log(iter.next().value); // 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
}
We can also use *[Symbol.iterator]() directly in a class to combine both in one:
class EvenNumbers {
constructor(limit) { this.limit = limit; }
*[Symbol.iterator]() {
for (let i = 0; i <= this.limit; i += 2) yield i;
}
}
console.log([...new EvenNumbers(10)]); // [0, 2, 4, 6, 8, 10]
Symbol.toPrimitive — Controlling Type Conversion
When JavaScript needs to convert our object to a primitive (number, string, or default), it calls [Symbol.toPrimitive](hint). The hint tells us what type is expected.
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.amount;
if (hint === "string") return `${this.amount} ${this.currency}`;
return this.amount; // default
}
}
const price = new Money(42, "USD");
console.log(`Price: ${price}`); // "Price: 42 USD" (hint = string)
console.log(+price); // 42 (hint = number)
console.log(price + 10); // 52 (hint = default)
Without this, JavaScript would just give us "[object Object]" for string conversion. Now our object converts meaningfully.
Symbol.hasInstance — Customizing instanceof
instanceof calls [Symbol.hasInstance] on the right-hand side. We can override it to change what instanceof means for our class.
class EvenNumber {
static [Symbol.hasInstance](num) {
return typeof num === "number" && num % 2 === 0;
}
}
console.log(4 instanceof EvenNumber); // true
console.log(7 instanceof EvenNumber); // false
console.log("hi" instanceof EvenNumber); // false
This is unusual — most of the time we don’t need to override instanceof. But it’s good to know it’s possible.
Symbol.species — Controlling Derived Constructors
When we extend Array or Promise, methods like .map() and .then() need to create new instances. Symbol.species tells them which constructor to use.
class FilteredArray extends Array {
static get [Symbol.species]() {
return Array; // .map() returns a plain Array, not FilteredArray
}
}
const filtered = new FilteredArray(1, 2, 3);
const doubled = filtered.map(x => x * 2);
console.log(doubled instanceof FilteredArray); // false
console.log(doubled instanceof Array); // true
This is handy when our subclass has expensive constructors or special initialization that shouldn’t run for every intermediate operation.
Symbol.toStringTag
When we call Object.prototype.toString.call(something), it returns "[object Type]". We can control Type with Symbol.toStringTag.
class Database {
get [Symbol.toStringTag]() {
return "Database";
}
}
const db = new Database();
console.log(Object.prototype.toString.call(db)); // [object Database]
This is useful for debugging — we can give our custom classes meaningful type tags instead of the generic "[object Object]".
Quick Reference
| Symbol | What It Controls | Triggered By |
|---|---|---|
Symbol.iterator | Iteration protocol | for...of, spread, destructuring |
Symbol.toPrimitive | Type conversion | +obj, ${obj}, comparisons |
Symbol.hasInstance | instanceof check | x instanceof MyClass |
Symbol.species | Derived constructors | .map(), .filter(), .then() |
Symbol.toStringTag | String representation | Object.prototype.toString.call() |
In simple language, well-known Symbols are hooks that let our custom objects plug into JavaScript’s built-in operations. The most practically useful one is Symbol.iterator — it lets any object work with for...of and spread syntax, which comes up all the time.