How Iterables and Iterators Work in JavaScript
Many of JavaScript's most useful features, such as for...of loops, the spread operator, destructuring, and Array.from(), all depend on a shared mechanism called the iteration protocol. This protocol defines a standard way for any object to declare "I have a sequence of values you can go through one by one."
When you write for (let char of "hello") or [...mySet], JavaScript is not using special hard-coded logic for each data type. Instead, it follows a universal protocol that strings, arrays, Maps, Sets, and any custom object can implement. Understanding this protocol reveals the common thread connecting many seemingly unrelated language features and gives you the power to make your own objects work seamlessly with all of them.
This guide explains the two protocols that make iteration possible (the iterable protocol and the iterator protocol), shows you exactly what happens under the hood when for...of runs, demonstrates how to make your own objects iterable, and covers the practical tools that consume iterables.
The Iterable Protocol: Symbol.iterator
The iterable protocol defines how an object declares that it can be iterated. Any object that implements this protocol is called an iterable.
The rule is simple: an object is iterable if it has a method at the special key Symbol.iterator that returns an iterator object.
// Checking if something is iterable
let str = "hello";
let num = 42;
let arr = [1, 2, 3];
console.log(typeof str[Symbol.iterator]); // "function" (iterable!)
console.log(typeof arr[Symbol.iterator]); // "function" (iterable!)
console.log(typeof num[Symbol.iterator]); // "undefined" (NOT iterable)
Built-In Iterables
JavaScript has several built-in iterable types:
| Type | Iterable? | What It Iterates Over |
|---|---|---|
String | Yes | Characters (code points) |
Array | Yes | Elements |
Map | Yes | [key, value] entries |
Set | Yes | Values |
TypedArray | Yes | Elements |
arguments | Yes | Arguments |
NodeList | Yes | DOM nodes |
Plain Object {} | No | Not iterable by default |
Number, Boolean | No | Not iterable |
// All of these work with for...of because they are iterable
for (let char of "abc") console.log(char); // a, b, c
for (let num of [1, 2, 3]) console.log(num); // 1, 2, 3
for (let entry of new Map([["a", 1]])) console.log(entry); // ["a", 1]
for (let val of new Set([1, 2, 3])) console.log(val); // 1, 2, 3
// Plain objects are NOT iterable
let obj = { a: 1, b: 2 };
// for (let val of obj) {} // TypeError: obj is not iterable
Plain objects are not iterable by default because JavaScript distinguishes between data structures (where iteration order matters) and records/dictionaries (where properties represent named fields, not a sequence). If you need to iterate over an object's entries, use Object.keys(), Object.values(), or Object.entries(), which return arrays (which are iterable).
The Symbol.iterator Method
When JavaScript needs to iterate over an object, it calls the object's [Symbol.iterator]() method. This method must return an iterator, which is an object with a next() method.
let arr = [10, 20, 30];
// Manually calling Symbol.iterator
let iterator = arr[Symbol.iterator]();
console.log(typeof iterator); // "object"
console.log(typeof iterator.next); // "function"
The Symbol.iterator method is the factory that produces iterator objects. Each call can return a fresh iterator, allowing multiple independent iterations over the same data.
The Iterator Protocol: next(), { value, done }
The iterator protocol defines how values are produced one at a time. An iterator is any object that has a next() method which returns an object with two properties:
value: the next value in the sequencedone: a boolean indicating whether the sequence is finished
let arr = [10, 20, 30];
let iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // { value: 10, done: false }
console.log(iterator.next()); // { value: 20, done: false }
console.log(iterator.next()); // { value: 30, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
console.log(iterator.next()); // { value: undefined, done: true } (stays done)
Each call to next() advances the iterator by one step. When all values have been produced, done becomes true and value becomes undefined. Once an iterator is exhausted, calling next() continues to return { value: undefined, done: true }.
The Two Properties Explained
While iterating (done: false):
The value property contains the current element, and done is false, signaling "there are more values to come."
When finished (done: true):
The value property is typically undefined (though it can optionally carry a "return value"), and done is true, signaling "the sequence is complete."
let set = new Set(["a", "b"]);
let iter = set[Symbol.iterator]();
let result = iter.next();
while (!result.done) {
console.log(result.value);
result = iter.next();
}
Output:
a
b
Iterators Are Stateful and One-Way
An iterator maintains internal state tracking its current position. It can only move forward, never backward. Once consumed, an iterator cannot be reset:
let arr = [1, 2, 3];
let iter = arr[Symbol.iterator]();
// Consume the iterator
iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: undefined, done: true }
// Cannot rewind (must create a new iterator)
let iter2 = arr[Symbol.iterator]();
console.log(iter2.next()); // { value: 1, done: false } (starts fresh)
Multiple Independent Iterators
Because Symbol.iterator is a method that creates a new iterator each time it is called, you can have multiple independent iterations running simultaneously:
let arr = [1, 2, 3];
let iter1 = arr[Symbol.iterator]();
let iter2 = arr[Symbol.iterator]();
console.log(iter1.next().value); // 1
console.log(iter1.next().value); // 2
console.log(iter2.next().value); // 1 (independent position!)
console.log(iter1.next().value); // 3
console.log(iter2.next().value); // 2
for...of Under the Hood
The for...of loop is the primary consumer of iterables. Understanding what it does internally removes all mystery from its behavior.
The Desugared Version
When you write:
for (let value of iterable) {
console.log(value);
}
JavaScript effectively does this:
// Step 1: Get the iterator by calling Symbol.iterator
let iterator = iterable[Symbol.iterator]();
// Step 2: Repeatedly call next() until done is true
let result = iterator.next();
while (!result.done) {
let value = result.value;
console.log(value); // Your loop body runs here
result = iterator.next();
}
Verifying the Equivalence
let fruits = ["apple", "banana", "cherry"];
// Using for...of
console.log("--- for...of ---");
for (let fruit of fruits) {
console.log(fruit);
}
// Manual equivalent
console.log("--- manual ---");
let iter = fruits[Symbol.iterator]();
let step = iter.next();
while (!step.done) {
console.log(step.value);
step = iter.next();
}
Output:
--- for...of ---
apple
banana
cherry
--- manual ---
apple
banana
cherry
Early Termination with break
When you use break inside for...of, the loop calls the iterator's optional return() method (if it exists) to signal early termination. This allows iterators to clean up resources:
let arr = [1, 2, 3, 4, 5];
for (let num of arr) {
if (num === 3) break; // Stops iteration early
console.log(num);
}
Output:
1
2
The return() method is part of the optional iterator protocol. Built-in iterators like array iterators implement it, but it typically does nothing for simple sequences. It becomes important for generators and iterators that hold resources (file handles, database connections).
What Happens with Non-Iterables
If you try to use for...of on a non-iterable value, JavaScript throws a TypeError:
let num = 42;
// for (let n of num) {} // TypeError: num is not iterable
let obj = { a: 1, b: 2 };
// for (let v of obj) {} // TypeError: obj is not iterable
The error message is clear and direct. JavaScript checks for Symbol.iterator, finds nothing, and reports the failure.
Strings as Iterables (Including Surrogate Pairs)
Strings are iterable, and their iterator is designed to handle Unicode correctly by iterating over code points rather than code units.
Basic String Iteration
for (let char of "Hello") {
console.log(char);
}
Output:
H
e
l
l
o
Correct Emoji and Surrogate Pair Handling
This is where string iteration shines compared to index-based access. Many emoji and characters outside the Basic Multilingual Plane are stored as surrogate pairs (two UTF-16 code units). The string iterator correctly groups them as single characters:
let text = "Hi 😀!";
// ❌ Index-based access breaks surrogate pairs
for (let i = 0; i < text.length; i++) {
console.log(i, text[i]);
}
// 0 "H"
// 1 "i"
// 2 " "
// 3 "�" (broken! high surrogate)
// 4 "�" (broken! low surrogate)
// 5 "!"
console.log("---");
// ✅ for...of handles surrogate pairs correctly
for (let char of text) {
console.log(char);
}
// H
// i
//
// 😀 (correctly grouped as one character)
// !
Manual String Iterator
let emoji = "A😀B";
let iter = emoji[Symbol.iterator]();
console.log(iter.next()); // { value: "A", done: false }
console.log(iter.next()); // { value: "😀", done: false } (full emoji, not half)
console.log(iter.next()); // { value: "B", done: false }
console.log(iter.next()); // { value: undefined, done: true }
The string iterator reads the internal UTF-16 representation and correctly assembles surrogate pairs into single code points before returning them. This is why for...of and the spread operator are the recommended ways to process string characters.
Comparing Iteration Methods on Strings
let str = "café ☕";
// length counts UTF-16 code units
console.log(str.length); // 7 (if ☕ is a BMP character, length matches)
// Spread uses the iterator (correct characters)
let chars = [...str];
console.log(chars); // ["c", "a", "f", "é", " ", "☕"]
console.log(chars.length); // 6
// Array.from also uses the iterator
let chars2 = Array.from(str);
console.log(chars2); // ["c", "a", "f", "é", " ", "☕"]
Making Custom Objects Iterable
Any object can become iterable by implementing the Symbol.iterator method. This is one of the most powerful customization points in JavaScript, allowing your objects to work with for...of, spread, destructuring, and every other iteration-based feature.
A Simple Range Iterator
let range = {
start: 1,
end: 5,
[Symbol.iterator]() {
let current = this.start;
let last = this.end;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
} else {
return { done: true };
}
}
};
}
};
// Now it works with for...of!
for (let num of range) {
console.log(num);
}
// 1
// 2
// 3
// 4
// 5
// And with spread!
console.log([...range]); // [1, 2, 3, 4, 5]
// And with destructuring!
let [first, second, ...rest] = range;
console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5]
Let's break down how this works:
rangehas a[Symbol.iterator]()method, making it iterable.- When
for...ofstarts, it callsrange[Symbol.iterator](), which returns a new iterator object. - The iterator object has a
next()method that trackscurrentvia closure. - Each call to
next()either returns the next value or signals completion with{ done: true }.
Important: Each Iteration Creates a Fresh Iterator
Notice that [Symbol.iterator]() returns a new object each time. This means you can iterate over the range multiple times, and each iteration starts from the beginning:
let range = {
start: 1,
end: 5,
[Symbol.iterator]() {
let current = this.start;
let last = this.end;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
} else {
return { done: true };
}
}
};
}
};
for (let n of range) {
if (n > 2) break;
console.log(n);
}
// 1
// 2
// Starting a new iteration (begins from start again)
for (let n of range) {
console.log(n);
}
// 1
// 2
// 3
// 4
// 5
If Symbol.iterator returned the same iterator object every time, the second loop would see an already-exhausted iterator and produce no output.
An Object That Is Its Own Iterator
In some cases, you might want an object to be both the iterable and the iterator. This is done by having Symbol.iterator return this:
let countdown = {
current: 5,
next() {
if (this.current > 0) {
return { value: this.current--, done: false };
}
return { done: true };
},
[Symbol.iterator]() {
return this; // The object IS the iterator
}
};
for (let n of countdown) {
console.log(n);
}
// 5
// 4
// 3
// 2
// 1
// ⚠️ Second iteration produces nothing (iterator is exhausted!)
for (let n of countdown) {
console.log(n);
}
// (no output: current is already 0)
This pattern is simpler but has a downside: the iterable can only be iterated once. After the first loop, the internal state is consumed. This is the pattern used by generators and many built-in iterators (like Map.keys()).
If your [Symbol.iterator]() returns this, the iterable can only be iterated once. If it creates and returns a new iterator object each time, the iterable can be iterated multiple times. Choose based on your use case.
Practical Example: Iterable Linked List
class LinkedList {
constructor() {
this.head = null;
}
add(value) {
this.head = { value, next: this.head };
return this;
}
[Symbol.iterator]() {
let current = this.head;
return {
next() {
if (current) {
let value = current.value;
current = current.next;
return { value, done: false };
}
return { done: true };
}
};
}
}
let list = new LinkedList();
list.add("cherry").add("banana").add("apple");
// for...of works!
for (let item of list) {
console.log(item);
}
// apple
// banana
// cherry
// Spread works!
console.log([...list]); // ["apple", "banana", "cherry"]
// Destructuring works!
let [first, ...rest] = list;
console.log(first); // "apple"
console.log(rest); // ["banana", "cherry"]
Practical Example: Paginated Data Iterator
function createPaginator(data, pageSize) {
return {
[Symbol.iterator]() {
let index = 0;
return {
next() {
if (index < data.length) {
let page = data.slice(index, index + pageSize);
index += pageSize;
return { value: page, done: false };
}
return { done: true };
}
};
}
};
}
let items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
let paginator = createPaginator(items, 3);
for (let page of paginator) {
console.log(page);
}
// [1, 2, 3]
// [4, 5, 6]
// [7, 8, 9]
// [10, 11]
Infinite Iterators
Iterators do not have to end. An iterator that never returns { done: true } produces an infinite sequence. Just make sure to use break or other controls to stop consuming it:
let fibonacci = {
[Symbol.iterator]() {
let a = 0, b = 1;
return {
next() {
let value = a;
[a, b] = [b, a + b];
return { value, done: false }; // Never done!
}
};
}
};
// ⚠️ Must use break (this is infinite!)
for (let n of fibonacci) {
if (n > 100) break;
console.log(n);
}
// 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
// Take first 10 values using destructuring
let iter = fibonacci[Symbol.iterator]();
let first10 = Array.from({ length: 10 }, () => iter.next().value);
console.log(first10);
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Never use the spread operator or Array.from() on an infinite iterator without limiting it first. [...fibonacci] would try to create an infinitely large array and crash your program.
Array.from(): Converting Iterables and Array-Likes
Array.from() is one of the primary consumers of the iteration protocol. It creates a new array from either an iterable or an array-like object.
From Iterables
Any iterable can be converted to an array with Array.from():
// From a string (iterable)
console.log(Array.from("hello")); // ["h", "e", "l", "l", "o"]
// From a Set (iterable)
let set = new Set([1, 2, 3, 2, 1]);
console.log(Array.from(set)); // [1, 2, 3]
// From a Map (iterable)
let map = new Map([["a", 1], ["b", 2]]);
console.log(Array.from(map)); // [["a", 1], ["b", 2]]
// From a custom iterable
let range = {
start: 1,
end: 5,
[Symbol.iterator]() {
let current = this.start;
let end = this.end;
return {
next() {
return current <= end
? { value: current++, done: false }
: { done: true };
}
};
}
};
console.log(Array.from(range)); // [1, 2, 3, 4, 5]
From Array-Likes
An array-like object is any object with a length property and indexed elements (like 0, 1, 2, etc.). Array-likes are not iterable (unless they also implement Symbol.iterator), but Array.from() can still convert them:
// Array-like object
let arrayLike = {
0: "apple",
1: "banana",
2: "cherry",
length: 3
};
// Not iterable (for...of won't work)
// for (let item of arrayLike) {} // TypeError: arrayLike is not iterable
// But Array.from works!
let arr = Array.from(arrayLike);
console.log(arr); // ["apple", "banana", "cherry"]
// Now it's a real array with all array methods
console.log(arr.map(s => s.toUpperCase())); // ["APPLE", "BANANA", "CHERRY"]
Common Array-Like Objects in Practice
// DOM NodeList (array-like and iterable in modern browsers)
// let divs = document.querySelectorAll("div");
// let divArray = Array.from(divs);
// arguments object (array-like, iterable in modern JS)
function example() {
let args = Array.from(arguments);
console.log(args);
}
example(1, 2, 3); // [1, 2, 3]
The Mapping Function (Second Argument)
Array.from() accepts an optional mapping function as its second argument, which is applied to each element during conversion. This is more efficient than Array.from(iterable).map(fn) because it does not create an intermediate array:
// Create and transform in a single step
let doubled = Array.from([1, 2, 3], n => n * 2);
console.log(doubled); // [2, 4, 6]
// Generate a sequence
let sequence = Array.from({ length: 5 }, (_, i) => i + 1);
console.log(sequence); // [1, 2, 3, 4, 5]
// Generate alphabet
let alphabet = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i));
console.log(alphabet); // ["A", "B", "C", ..., "Z"]
// Create a multiplication table row
let row = Array.from({ length: 10 }, (_, i) => (i + 1) * 7);
console.log(row); // [7, 14, 21, 28, 35, 42, 49, 56, 63, 70]
Array.from vs. Iterable Priority
When an object is both iterable and array-like, Array.from() uses the iterator (Symbol.iterator) rather than the array-like interface:
let hybrid = {
0: "index-zero",
1: "index-one",
length: 2,
[Symbol.iterator]() {
let values = ["iter-a", "iter-b", "iter-c"];
let i = 0;
return {
next() {
return i < values.length
? { value: values[i++], done: false }
: { done: true };
}
};
}
};
// Iterator takes priority
console.log(Array.from(hybrid));
// ["iter-a", "iter-b", "iter-c"] (from iterator, not from indices!)
Spread Syntax with Iterables
The spread operator (...) works with any iterable, expanding it into individual elements.
Spread in Array Literals
let str = "hello";
let chars = [...str];
console.log(chars); // ["h", "e", "l", "l", "o"]
let set = new Set([3, 1, 4, 1, 5]);
let arr = [...set];
console.log(arr); // [3, 1, 4, 5]
// Custom iterable
let range = {
start: 1,
end: 5,
[Symbol.iterator]() {
let current = this.start;
let end = this.end;
return {
next() {
return current <= end
? { value: current++, done: false }
: { done: true };
}
};
}
};
console.log([...range]); // [1, 2, 3, 4, 5]
// Combining iterables
console.log([...range, ...set]); // [1, 2, 3, 4, 5, 3, 1, 4, 5]
Spread in Function Calls
let numbers = [5, 3, 8, 1, 9];
console.log(Math.max(...numbers)); // 9
function greet(first, second, third) {
console.log(`${first}, ${second}, and ${third}`);
}
let names = new Set(["Alice", "Bob", "Charlie"]);
greet(...names); // "Alice, Bob, and Charlie"
Spread vs. Array.from
Both can convert iterables to arrays, but they work differently with array-likes:
let arrayLike = { 0: "a", 1: "b", length: 2 };
// ✅ Array.from handles array-likes
console.log(Array.from(arrayLike)); // ["a", "b"]
// ❌ Spread does NOT work with array-likes (only iterables)
// console.log([...arrayLike]); // TypeError: arrayLike is not iterable
| Feature | [...iterable] | Array.from(source) |
|---|---|---|
| Works with iterables | Yes | Yes |
| Works with array-likes | No | Yes |
| Mapping function | No (chain .map()) | Yes (second argument) |
| Syntax | Concise | Slightly verbose |
Use spread ([...iterable]) for quick, concise conversions of iterables. Use Array.from() when working with array-likes, when you need the mapping function, or when the source might be either iterable or array-like.
Spread with Objects: Not the Same Protocol
The spread operator in object literals ({...obj}) does not use the iteration protocol. It copies enumerable own properties:
let iterable = {
a: 1,
b: 2,
[Symbol.iterator]() {
let values = [10, 20, 30];
let i = 0;
return {
next() {
return i < values.length
? { value: values[i++], done: false }
: { done: true };
}
};
}
};
// Array spread uses the iterator
console.log([...iterable]); // [10, 20, 30]
// Object spread copies properties (ignores iterator)
console.log({...iterable}); // { a: 1, b: 2 }
Other Consumers of Iterables
Beyond for...of, spread, and Array.from, many other JavaScript features consume iterables:
Destructuring Assignment
let [a, b, c] = new Set(["x", "y", "z"]);
console.log(a, b, c); // x y z
let [first, ...rest] = "hello";
console.log(first); // "h"
console.log(rest); // ["e", "l", "l", "o"]
let [x, y] = new Map([["key1", 1], ["key2", 2]]);
console.log(x); // ["key1", 1]
console.log(y); // ["key2", 2]
Promise.all, Promise.race, Promise.allSettled
These accept iterables of promises:
let promises = new Set([
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/comments")
]);
// Promise.all accepts any iterable, not just arrays
let results = await Promise.all(promises);
Map and Set Constructors
Both accept iterables in their constructors:
// Map from an iterable of [key, value] pairs
let map = new Map([["a", 1], ["b", 2]]);
// Set from any iterable
let set = new Set("hello"); // Set from string iterable
console.log(set); // Set { "h", "e", "l", "o" } (duplicates removed)
// WeakMap and WeakSet also accept iterables
yield* in Generators
The yield* expression delegates to another iterable:
function* concat(...iterables) {
for (let iterable of iterables) {
yield* iterable; // Delegates to each iterable
}
}
let combined = [...concat([1, 2], new Set([3, 4]), "ab")];
console.log(combined); // [1, 2, 3, 4, "a", "b"]
Iterable vs. Array-Like: Key Differences
These two concepts are often confused but are fundamentally different:
Iterable: Has a [Symbol.iterator]() method. Can be used with for...of, spread, destructuring.
Array-like: Has a length property and indexed elements (0, 1, 2, etc.). Can be used with Array.from() and indexed loops.
An object can be one, both, or neither:
// Both iterable AND array-like: Arrays, Strings, TypedArrays
let arr = [1, 2, 3];
console.log(arr.length); // 3 (array-like)
console.log(arr[Symbol.iterator]); // function (iterable)
// Iterable but NOT array-like: Set, Map
let set = new Set([1, 2, 3]);
console.log(set.length); // undefined (NOT array-like)
console.log(set[Symbol.iterator]); // function (iterable)
// Array-like but NOT iterable: plain object with length
let arrayLike = { 0: "a", 1: "b", length: 2 };
console.log(arrayLike.length); // 2 (array-like)
console.log(arrayLike[Symbol.iterator]); // undefined (NOT iterable)
// Neither: plain object without length
let plain = { a: 1, b: 2 };
// Not array-like (no length), not iterable (no Symbol.iterator)
| Feature | Iterable | Array-Like |
|---|---|---|
| Required | [Symbol.iterator]() method | length property + indexed elements |
for...of | Yes | No |
Spread [...] | Yes | No |
Array.from() | Yes | Yes |
| Destructuring | Yes | No |
Traditional for loop | Only if also indexed | Yes |
Summary
- The iterable protocol requires an object to have a
[Symbol.iterator]()method that returns an iterator. This makes the object usable withfor...of, spread, destructuring, and other iteration features. - The iterator protocol requires an object to have a
next()method that returns{ value, done }. Each call advances the iterator, anddone: truesignals the end of the sequence. for...ofinternally calls[Symbol.iterator]()to get an iterator, then repeatedly callsnext()untildoneistrue.- Strings are iterable and their iterator correctly handles surrogate pairs (emoji and characters outside the BMP), unlike index-based access.
- You can make any object iterable by implementing
[Symbol.iterator](). If the method returns a new iterator each time, the object can be iterated multiple times. If it returnsthis, the object is a one-shot iterator. Array.from()converts both iterables and array-likes to arrays. It accepts an optional mapping function as a second argument.- The spread operator (
...) works with iterables in array literals and function calls. It does not work with plain array-like objects. Object spread ({...obj}) uses property copying, not the iteration protocol. - Array-like objects (with
lengthand indexed properties) are different from iterables.Array.from()handles both, butfor...ofand spread require iterables. - Built-in iterables include strings, arrays, Maps, Sets, TypedArrays,
arguments, and NodeLists. Plain objects are not iterable by default. - Many JavaScript features consume iterables: destructuring,
Promise.all,Map/Setconstructors,yield*, and more. Making your objects iterable unlocks compatibility with the entire iteration ecosystem.