Skip to main content

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:

TypeIterable?What It Iterates Over
StringYesCharacters (code points)
ArrayYesElements
MapYes[key, value] entries
SetYesValues
TypedArrayYesElements
argumentsYesArguments
NodeListYesDOM nodes
Plain Object {}NoNot iterable by default
Number, BooleanNoNot 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
Why Are Plain Objects 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 sequence
  • done: 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:

  1. range has a [Symbol.iterator]() method, making it iterable.
  2. When for...of starts, it calls range[Symbol.iterator](), which returns a new iterator object.
  3. The iterator object has a next() method that tracks current via closure.
  4. 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()).

One-Shot vs. Reusable Iterables

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]
Infinite Iterators and Spread

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 iterablesYesYes
Works with array-likesNoYes
Mapping functionNo (chain .map())Yes (second argument)
SyntaxConciseSlightly verbose
When to Use Which

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)
FeatureIterableArray-Like
Required[Symbol.iterator]() methodlength property + indexed elements
for...ofYesNo
Spread [...]YesNo
Array.from()YesYes
DestructuringYesNo
Traditional for loopOnly if also indexedYes

Summary

  • The iterable protocol requires an object to have a [Symbol.iterator]() method that returns an iterator. This makes the object usable with for...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, and done: true signals the end of the sequence.
  • for...of internally calls [Symbol.iterator]() to get an iterator, then repeatedly calls next() until done is true.
  • 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 returns this, 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 length and indexed properties) are different from iterables. Array.from() handles both, but for...of and 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/Set constructors, yield*, and more. Making your objects iterable unlocks compatibility with the entire iteration ecosystem.