Symbols, Iterators, and Generators
The Protocol That Powers for...of
Ever wonder what actually happens when you write for (const item of array)? JavaScript doesn't just loop over the array directly. It calls a specific protocol — the iterable protocol — which is defined using a Symbol. Once you understand this protocol, you can make any object iterable, build lazy sequences that process a million items in constant memory, and stream data with async generators.
Think of the iterable protocol as a vending machine contract. Any object can become a vending machine by implementing one method: [Symbol.iterator](). This method returns a dispenser (an iterator) with a next() button. Each press of next() returns { value, done }. When done is true, the machine is empty. for...of, spread, destructuring — they all just press the button repeatedly until done.
Symbols — Unique, Collision-Free Keys
Before we get to iterators, let's talk about the primitive that makes them possible. A Symbol is a unique primitive value. Two symbols are never equal, even if they have the same description:
const a = Symbol("id");
const b = Symbol("id");
a === b; // false — always unique
// Symbols as property keys — no collision with string keys
const obj = {};
obj[a] = "value for a";
obj[b] = "value for b";
obj["id"] = "string key";
// All three coexist without conflict
Well-Known Symbols
The language defines special symbols that control object behavior:
| Symbol | Controls |
|---|---|
Symbol.iterator | How for...of, spread, destructuring work |
Symbol.toPrimitive | How objects convert to primitives |
Symbol.hasInstance | How instanceof works |
Symbol.toStringTag | What Object.prototype.toString returns |
Symbol.asyncIterator | How for await...of works |
Symbol.species | Constructor to use for derived objects |
class CustomList {
constructor(...items) {
this.items = items;
}
// Make instances work with for...of
[Symbol.iterator]() {
let index = 0;
return {
next: () => ({
value: this.items[index],
done: index++ >= this.items.length
})
};
}
// Customize instanceof
static [Symbol.hasInstance](instance) {
return Array.isArray(instance.items);
}
}
const list = new CustomList("a", "b", "c");
for (const item of list) console.log(item); // "a", "b", "c"
[...list]; // ["a", "b", "c"]
const [first] = list; // "a"
The Iterable Protocol
An object is iterable if it has a [Symbol.iterator] method that returns an iterator. An iterator is any object with a next() method that returns { value, done }.
// Making a range iterable
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
return current <= last
? { value: current++, done: false }
: { done: true }; // value is implicitly undefined
}
};
}
};
for (const n of range) console.log(n); // 1, 2, 3, 4, 5
[...range]; // [1, 2, 3, 4, 5]
Strings are iterable, and they iterate over Unicode code points, not UTF-16 code units. This matters for emoji and other characters outside the Basic Multilingual Plane:
const emoji = "Hello ";
emoji.length; // 8 (UTF-16 code units — the emoji is 2 units)
[...emoji].length; // 7 (code points — the emoji is 1 code point)
for (const char of emoji) console.log(char);
// "H", "e", "l", "l", "o", " ", "" — correct!If you use a for loop with index (emoji[i]), you'll get broken surrogate pairs for emoji. Always use for...of or spread for character-by-character string iteration.
Generator Functions — Iterators Made Easy
Writing iterator objects by hand is verbose and error-prone. Turns out there's a much better way — generator functions create them automatically:
function* range(from, to) {
for (let i = from; i <= to; i++) {
yield i; // Pause and return this value
}
}
const iter = range(1, 5);
iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: 4, done: false }
iter.next(); // { value: 5, done: false }
iter.next(); // { value: undefined, done: true }
for (const n of range(1, 5)) console.log(n); // 1, 2, 3, 4, 5
Yield Pauses Execution
The key insight: yield pauses the generator. The function's state (local variables, execution position) is preserved. The next next() call resumes from where it paused:
function* demo() {
console.log("Start");
const x = yield "first"; // Pauses here. x gets the value passed to next()
console.log("Got:", x);
const y = yield "second";
console.log("Got:", y);
return "done";
}
const gen = demo();
gen.next(); // Logs "Start", returns { value: "first", done: false }
gen.next("hello"); // Logs "Got: hello", returns { value: "second", done: false }
gen.next("world"); // Logs "Got: world", returns { value: "done", done: true }
yield* — Delegating to Another Iterator
function* concat(...iterables) {
for (const iterable of iterables) {
yield* iterable; // Delegates to each iterable's iterator
}
}
[...concat([1, 2], [3, 4], "ab")]; // [1, 2, 3, 4, "a", "b"]
Lazy Iteration — The Real Power
This is where generators go from "neat trick" to "genuinely powerful." They compute values on demand and don't create arrays in memory:
// Eager — creates all values immediately (uses memory)
function eagerRange(n) {
const result = [];
for (let i = 0; i < n; i++) result.push(i);
return result;
}
eagerRange(1_000_000); // Array with 1M elements in memory
// Lazy — computes one value at a time (constant memory)
function* lazyRange(n) {
for (let i = 0; i < n; i++) yield i;
}
// Only the current value exists in memory at any time
// Composable lazy operations
function* map(iterable, fn) {
for (const item of iterable) yield fn(item);
}
function* filter(iterable, predicate) {
for (const item of iterable) {
if (predicate(item)) yield item;
}
}
function* take(iterable, n) {
let count = 0;
for (const item of iterable) {
if (count++ >= n) return;
yield item;
}
}
// Chain them — processes ONE element at a time through the pipeline
const result = [...take(
filter(
map(lazyRange(1_000_000), x => x * 2),
x => x % 3 === 0
),
5
)];
// [0, 6, 12, 18, 24] — only computed 10 values total, not 1M
Infinite sequences with generators
Since generators are lazy, they can represent infinite sequences:
function* fibonacci() {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
// Take only what you need
[...take(fibonacci(), 10)]; // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]This is safe because the infinite loop only runs when next() is called. Without take(), [...fibonacci()] would run forever.
Async Generators — Streaming Data
What if you could combine the laziness of generators with async/await? You can. Async generators let you stream asynchronous data with elegant, readable code:
async function* fetchPages(url) {
let page = 1;
while (true) {
const response = await fetch(`${url}?page=${page}`);
const data = await response.json();
if (data.items.length === 0) return; // No more pages
yield* data.items;
page++;
}
}
// Consume with for await...of
for await (const item of fetchPages("/api/users")) {
processUser(item);
// Processes each page as it arrives
// Doesn't load all pages into memory at once
}
Production Scenario: Paginated API Client
async function* paginatedFetch(endpoint, pageSize = 100) {
let cursor = null;
do {
const url = new URL(endpoint);
url.searchParams.set("limit", pageSize);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url);
const { data, nextCursor } = await res.json();
for (const item of data) yield item;
cursor = nextCursor;
} while (cursor);
}
// Usage — clean, memory-efficient, and stops fetching when you break
for await (const user of paginatedFetch("/api/users")) {
if (user.role === "admin") {
console.log("Found admin:", user.name);
break; // Stops fetching more pages immediately
}
}
| What developers do | What they should do |
|---|---|
| Confusing iterable (has [Symbol.iterator]) with iterator (has next()) for...of calls [Symbol.iterator]() to get the iterator, then calls next() on it | An iterable produces an iterator. An iterator produces values. Some objects are both. |
| Using for...in instead of for...of for iteration for...in is for object keys. for...of is for iterable values. They solve different problems. | for...of uses the iterable protocol. for...in iterates enumerable string keys (including inherited). |
| Expecting generator body to run when you call the function The function body is lazy — it only executes in response to next() calls | Calling a generator function only creates the generator object. The body runs on first next() call. |
| Forgetting that [...infiniteGenerator()] hangs forever Spread and Array.from consume the entire iterator. Infinite iterators never return done: true. | Always use a take/limit mechanism with infinite generators |
- 1An iterable has [Symbol.iterator](). An iterator has next(). A generator function creates objects that are both.
- 2yield pauses execution and preserves all local state. The next .next() call resumes exactly where it left off.
- 3Generator functions don't run on call — they return a generator object. The body starts on first next().
- 4Generators are lazy — they compute values on demand, enabling infinite sequences and memory-efficient pipelines.
- 5Async generators (async function*) combine await and yield for streaming asynchronous data with for await...of.