Chapter 6.6☕ 19 min read

Iterators & the for...of Protocol

Magic nahi, Protocol hai — Symbol.iterator se next() tak, aur early break pe cleanup tak.

01Iterable vs Iterator: The Two Halves

An Iterable is any object that implements the [Symbol.iterator]() method. An Iterator is an object with a .next() method that returns { value, done }. They are two halves of one protocol — calling [Symbol.iterator]() on an iterable returns an iterator.

Built-in iterables: String, Array, Map, Set, NodeList, and arguments all implement the iteration protocol. When you write for...of, JavaScript simply calls [Symbol.iterator]() and keeps calling .next() until done is true. That's the entire mechanism — no magic, just a contract.

How for...of actually works under the hood:

// How for...of actually works under the hood
const arr = ['a', 'b', 'c'];

// This:
for (const item of arr) {
  console.log(item);
}

// Is equivalent to this:
const iterator = arr[Symbol.iterator]();
let result = iterator.next();
while (!result.done) {
  const item = result.value;
  console.log(item);
  result = iterator.next();
}

// Calling .next() manually on an array iterator
const it = arr[Symbol.iterator]();
console.log(it.next()); // { value: 'a', done: false }
console.log(it.next()); // { value: 'b', done: false }
console.log(it.next()); // { value: 'c', done: false }
console.log(it.next()); // { value: undefined, done: true }
02Custom Iterables: Build Your Own

Any object can be made iterable by adding a [Symbol.iterator]() method. This method must return an iterator object — an object with a .next() method. Each call to .next() must return { value, done }.

The iterator uses closure to maintain its state (current position). The [Symbol.iterator]() method creates a new scope where the state variable lives, and returns the iterator object that can access and update it.

// Custom iterable: range of numbers
const range = {
  from: 1,
  to: 5,
  
  [Symbol.iterator]() {
    // This returns the iterator object
    let current = this.from;
    const last = this.to;
    
    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

// Now we can use for...of on our custom object!
for (const num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}

// Spread also works!
const rangeArray = [...range]; // [1, 2, 3, 4, 5]
Why this works: When for...of encounters range, it calls range[Symbol.iterator](). That creates a fresh current variable in the closure. Each .next() call sees the updated current and returns the next value. This factory pattern means every for...of gets its own independent iterator.
03Iterator Closing: The .return() Method

If a for...of loop is exited early (via break, return, or throw), the iterator's .return() method is called if it exists. This is used for cleanup — closing files, releasing resources, etc.

The .return() method must return an object like { done: true }. If the iterator doesn't have .return(), nothing happens on early exit — no error, just no cleanup.

const iterable = {
  [Symbol.iterator]() {
    let step = 0;
    return {
      next() {
        step++;
        if (step <= 5) return { value: step, done: false };
        return { done: true };
      },
      return() {
        console.log('Iterator closed early! Cleanup time.');
        return { done: true };
      }
    };
  }
};

// Normal exit — .return() is NOT called
for (const val of iterable) {
  console.log(val); // 1, 2, 3, 4, 5
}

// Early exit — .return() IS called
for (const val of iterable) {
  if (val === 2) break;
  console.log(val); // 1
}
// Logs: "Iterator closed early! Cleanup time."
V8 guarantee: V8 ensures that .return() is called on early exits from for...of loops. This is implemented by wrapping the loop body in a try/finally block internally. If you break or throw, the finally block invokes iterator.return(). This makes for...of safer than manual iterator consumption for resources.
04Array-like vs Iterable: The Crucial Difference

Array-like and Iterable are often confused, but they are completely different contracts:

Array-like: Has numeric indices and a .length property (e.g., arguments, NodeList). May NOT have [Symbol.iterator].

Iterable: Has [Symbol.iterator] method (e.g., Map, Set, strings). May NOT have .length.

The crucial difference: Array.from() converts BOTH array-like AND iterable objects into real Arrays. But spread [...x] ONLY works with iterables — it FAILS on plain array-likes without the iterator protocol.
// Array-like (has length and indices, but NO Symbol.iterator)
const arrayLike = {
  0: 'Hello',
  1: 'World',
  length: 2
};

// [...arrayLike] // TypeError: arrayLike is not iterable!

// Fix 1: Array.from (works with array-likes AND iterables)
const arr1 = Array.from(arrayLike); // ['Hello', 'World'] ✅

// Fix 2: Add the iterator protocol manually
arrayLike[Symbol.iterator] = function() {
  let i = 0;
  return {
    next: () => i < this.length ?
      { value: this[i++], done: false } :
      { done: true }
  };
};
const arr2 = [...arrayLike]; // ['Hello', 'World'] ✅

// NodeList is both array-like AND iterable in modern browsers
// const nodes = document.querySelectorAll('div');
// [...nodes] // Works!
05String Iterator: Fixing Surrogate Pairs

Strings are iterable. for...of on a string iterates over code points, not UTF-16 code units. This fixes the emoji problem!

"🙂"[0] returns a broken surrogate half, but [..."🙂"] returns the whole emoji. The string iterator correctly handles surrogate pairs — characters outside the BMP (above U+FFFF).

const emoji = '🙂🎉';

// WRONG: Indexing splits surrogate pairs
console.log(emoji[0]); // broken high surrogate!
console.log(emoji.length); // 4 (two emojis = 4 UTF-16 code units!)

// RIGHT: String iterator handles surrogate pairs
const chars = [...emoji];
console.log(chars); // ['🙂', '🎉'] ✅
console.log(chars.length); // 2 (actual character count!)

// for...of also works correctly
for (const char of emoji) {
  console.log(char); // '🙂', then '🎉'
}

// Manual string iterator
const strIter = emoji[Symbol.iterator]();
console.log(strIter.next()); // { value: '🙂', done: false }
console.log(strIter.next()); // { value: '🎉', done: false }
console.log(strIter.next()); // { value: undefined, done: true }
Advanced: For true visual character splitting (grapheme clusters), even [...str] might not be enough — it handles surrogate pairs, but not combined characters like flags or skin-tone modifiers. Use Intl.Segmenter: new Intl.Segmenter('en', {granularity: 'grapheme'}).segment(str) for accurate visual character splitting.

Lo kar liya — Key Points:

  • ✅ Iterable: Object with [Symbol.iterator]() method. Iterator: Object with .next() method returning { value, done }.
  • for...of calls [Symbol.iterator]() and repeatedly calls .next() until done is true.
  • ✅ Custom iterables: Add [Symbol.iterator]() returning an object with .next() to make any object work with for...of and spread.
  • ✅ If a for...of loop exits early (break, throw), the iterator's .return() method is called for cleanup.
  • ✅ Array-like (has length/indices) ≠ Iterable (has Symbol.iterator). Array.from works with both; spread only works with iterables.
  • ✅ String iterators correctly handle surrogate pairs, making [...emoji] the right way to split strings with emojis.
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase