JavaScript Symbols, Iterators, and Custom Iterables
Intro context
JavaScript symbols iterators are two ES6 features that often fly under the radar but power much of modern JavaScript. The Symbol primitive creates unique property keys, while the iterator protocol defines how objects produce sequences of values. Together they form the basis for for...of, the spread operator, destructuring, and built-ins like Map and Set.
This tutorial walks through symbols, well-known symbols, the iterator and iterable protocols, generator functions, and built-in iterables. By the end you can build your own iterable types and understand why for...of works the way it does. For related fundamentals, see the JavaScript generators guide and the async iterators guide.
Understanding symbols
A Symbol is a primitive data type introduced in ES6. Unlike strings, numbers, or booleans, each Symbol is guaranteed to be unique. This makes them perfect for creating object properties that won’t collide with other keys.
Creating symbols
You create a Symbol using the Symbol() function. Optionally, you can provide a description for debugging purposes:
const sym1 = Symbol();
const sym2 = Symbol("mySymbol");
const sym3 = Symbol("mySymbol");
console.log(sym1); // Symbol()
console.log(sym2 === sym3); // false - each Symbol is unique
Even when two Symbols have the same description, they remain distinct. The description is purely for readability and debugging.
Using symbols as property keys
Symbols work as object property keys that won’t collide with string keys, making them useful for attaching metadata or internal hooks to objects without cluttering their public shape:
const uniqueKey = Symbol("unique");
const user = {
name: "Alice",
[uniqueKey]: "secret-value"
};
console.log(user.name); // "Alice"
console.log(user[uniqueKey]); // "secret-value"
// Symbol keys don't appear in Object.keys()
console.log(Object.keys(user)); // ["name"]
// But they do appear in Object.getOwnPropertySymbols()
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(unique)]
This makes Symbols ideal for adding private-like properties to objects, though they’re not truly private since they’re discoverable via Object.getOwnPropertySymbols().
Well-known symbols
JavaScript provides several well-known symbols that override default language behavior. These are global Symbol properties that modify how objects behave in specific scenarios.
Symbol.iterator
The most important well-known symbol is Symbol.iterator. It defines the default iterator for an object, enabling for...of loops and the spread operator:
const numbers = [1, 2, 3];
// Arrays have Symbol.iterator by default
const getIter = numbers[Symbol.iterator];
const iterator = getIter.call(numbers);
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
Each call to next() advances the iterator and returns the next value with done: false. When the sequence is exhausted, done flips to true and the loop stops. This protocol is what for...of and the spread operator rely on under the hood.
Symbol.tostringtag
This symbol customizes the output of Object.prototype.toString() — useful when you want Object.prototype.toString.call(obj) to return a descriptive label instead of the default [object Object]:
const user = {
[Symbol.toStringTag]: "User"
};
console.log(user.toString()); // "[object User]"
Setting Symbol.toStringTag changes what toString() reports without subclassing. Libraries often use this so debuggers and logging tools can display a meaningful type name.
Symbol.hasinstance
This symbol lets you customize the behavior of the instanceof operator. Instead of checking the prototype chain, you define a static method that runs arbitrary logic:
class EvenNumbers {
static [Symbol.hasInstance](number) {
return number % 2 === 0;
}
}
console.log(4 instanceof EvenNumbers); // true
console.log(7 instanceof EvenNumbers); // false
The iterator protocol
The iterator protocol defines how objects produce a sequence of values. An object is an iterator when it implements a next() method that returns an object with value and done properties.
Creating custom iterators
You can create your own iterator by implementing the required interface:
function createRangeIterator(start, end) {
let current = start;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
const iterator = createRangeIterator(1, 3);
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
This iterator produces values from 1 to 3, then signals completion. The done property tells the consumer when to stop iterating. An iterator on its own is useful, but to use it with for...of, spread syntax, or destructuring, the object also needs to implement the iterable protocol so JavaScript knows how to create a fresh iterator each time.
The iterable protocol
The iterable protocol allows objects to define their iteration behavior. An object is iterable when it implements a method keyed by Symbol.iterator that returns an iterator.
Making objects iterable
To make your custom objects iterable, add a method keyed by Symbol.iterator:
const fibonacci = {};
fibonacci[Symbol.iterator] = function () {
let a = 0, b = 1;
return {
next() {
const result = { value: b, done: false };
[a, b] = [b, a + b];
return result;
}
};
};
for (const num of fibonacci) {
if (num > 100) break;
console.log(num); // 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
}
This example creates an infinite Fibonacci sequence that stops when values exceed 100. The for...of loop handles the iterator protocol automatically. Because the iterable returns a fresh iterator each time, you can loop over the same object repeatedly without resetting state.
Practical iterable: a range object
A range object is a common use case for custom iterables. By implementing Symbol.iterator, you can iterate over numbers within a bounds pair with the same syntax you’d use for arrays:
function makeRange(start, end) {
const range = {};
range[Symbol.iterator] = function () {
let current = start;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { done: true };
}
};
};
return range;
}
// Use with for...of
for (const num of makeRange(5, 8)) {
console.log(num); // 5, 6, 7, 8
}
// Use with spread operator
const arr = [...makeRange(1, 5)];
console.log(arr); // [1, 2, 3, 4, 5]
// Use with Array.from
const typedArr = Array.from(makeRange(10, 13));
console.log(typedArr); // [10, 11, 12, 13]
This pattern is exactly how JavaScript’s built-in Set, Map, and array-like objects work. Once Symbol.iterator is defined, you get for...of, spread, destructuring, and Array.from support for free without writing extra methods.
Generator functions
Generator functions provide a simpler way to create iterables. They use the function* syntax and automatically implement the iterator protocol. Instead of manually tracking state and returning { value, done } objects, you write yield and the runtime does the bookkeeping:
function* countToThree() {
yield 1;
yield 2;
yield 3;
}
const generator = countToThree();
console.log(generator.next().value); // 1
console.log(generator.next().value); // 2
console.log(generator.next().value); // 3
console.log(generator.next().done); // true
Generators are iterable by default, so they work with for...of without any extra wiring. When you call a generator function, it returns a generator object that is both an iterator and an iterable — lazy evaluation means values are produced only as the loop requests them, which keeps memory use predictable.
function* fibonacciGenerator(limit) {
let a = 0, b = 1, count = 0;
while (count < limit) {
yield a;
[a, b] = [b, a + b];
count++;
}
}
for (const num of fibonacciGenerator(8)) {
console.log(num); // 0, 1, 1, 2, 3, 5, 8, 13
}
The yield keyword pauses execution and returns a value. When you call next() again, execution resumes from where it paused, with all local variables intact. This pause-and-resume behavior is what makes generators fundamentally different from regular functions — they maintain state between calls without needing a closure or an object to hold it.
Infinite iterables with generators
Because generators produce values lazily, they can represent sequences that would be impossible to store in memory all at once:
function* naturalNumbers() {
let n = 1;
while (true) {
yield n++;
}
}
const numbers = naturalNumbers();
console.log(numbers.next().value); // 1
console.log(numbers.next().value); // 2
console.log(numbers.next().value); // 3
// Take only what you need with for...of
for (const num of naturalNumbers()) {
if (num > 5) break;
console.log(num); // 1, 2, 3, 4, 5
}
The iteration only proceeds as far as needed, so the infinite generator doesn’t cause problems.
Built-in iterables
Several JavaScript built-in types are iterable out of the box:
- Arrays and TypedArrays
- Strings (iterate over Unicode code points)
- Maps
- Sets
- Arguments objects
- DOM NodeLists and HTMLCollections
// Strings are iterable
for (const char of "hello") {
console.log(char); // h, e, l, l, o
}
// Maps are iterable (entries by default)
const map = new Map([["a", 1], ["b", 2]]);
for (const [key, value] of map) {
console.log(key, value); // a 1, b 2
}
// Sets are iterable
const set = new Set([1, 2, 3]);
for (const item of set) {
console.log(item); // 1, 2, 3
}
Common iterator methods
Several built-in methods work with any iterable, not just arrays. Array.from() and the spread operator are the two most common, but destructuring and for...of also consume iterables directly. This means custom iterables integrate with the entire JavaScript collection API without any extra code:
const arr = [1, 2, 3, 4, 5];
// Array.from converts iterables to arrays
const fromArray = Array.from(makeRange(1, 3));
console.log(fromArray); // [1, 2, 3]
// Spread operator works with iterables
const spreadArray = [...makeRange(1, 3)];
console.log(spreadArray); // [1, 2, 3]
// Destructuring works with iterables
const [first, second, ...rest] = makeRange(1, 5);
console.log(first, second, rest); // 1 2 [3, 4, 5]
// Array methods that return iterables
const mapped = [...arr.map(x => x * 2)];
console.log(mapped); // [2, 4, 6, 8, 10]
const filtered = [...arr.filter(x => x > 3)];
console.log(filtered); // [4, 5]
Summary
Symbols provide a way to create unique property keys, preventing accidental collisions in objects. Well-known symbols like Symbol.iterator let you customize how objects behave with language features.
The iterator protocol defines a standard way to produce sequences of values. Objects with a next() method returning { value, done } are iterators. The iterable protocol lets objects specify how they should be iterated using Symbol.iterator.
Together, these features enable for...of loops, the spread operator, destructuring, and many built-in JavaScript methods. Generator functions (function*) provide a convenient syntax for creating iterables without manually implementing the protocol.
These patterns appear throughout modern JavaScript—from arrays and strings to Maps, Sets, and custom data structures. Understanding them opens up new possibilities for creating flexible, iterable types in your applications.
In the next tutorial, you’ll explore classes and prototypes (JavaScript’s object-oriented programming features) that build on these concepts.
Symbols in Practice
Symbols are most useful when you need a property name that will not collide with ordinary keys. That makes them a good fit for internal hooks, library metadata, and framework conventions. Because symbol keys are not part of normal string lookup, they help keep a public object shape clean while still letting advanced code attach behavior. When you design an API around symbols, document the ones callers should know about and keep the rest private by convention.
Designing custom iterables
Custom iterables are easiest to maintain when their iteration order is obvious. If the sequence has a natural beginning and end, model that directly in the iterator rather than making callers guess how to read it. A small, predictable next() implementation is often easier to reason about than a clever one. That clarity matters when the iterable is used in for...of, spread syntax, or destructuring, because all of those features depend on the same protocol.
Iterator Cleanup
Iterators can also manage cleanup when iteration ends early. If you build a generator or manual iterator that opens resources, think about what happens when a loop breaks before it consumes every value. Releasing resources at the right time keeps the object safe to reuse and avoids leaks in long-running processes. That is especially important for iterables that wrap files, streams, or other external inputs. A clean exit path should be part of the design, not an afterthought.
Debugging Iteration
When iteration behaves strangely, check the contract first. Does the object actually return an iterator? Does next() return the right shape? Does the done flag flip when the sequence ends? Those small checks often reveal the bug faster than looking at the consumer code. Iteration errors can feel mysterious because the syntax is compact, but the protocol itself is simple. If you test the protocol directly, the hidden mismatch usually shows itself quickly.