JavaScript Iterators

An iterator is an object that lets you step through a sequence of values one at a time, on demand, without needing the whole collection to exist in memory at once. Iterators are the low-level mechanism that powers for...of loops, the spread operator, destructuring, and many other built-in JavaScript features. Understanding them lets you make your own custom objects “loopable” and build lazy, memory-efficient sequences of your own.

Overview: How Iterators Work

JavaScript defines two related protocols that work together: the iterable protocol and the iterator protocol. They are separate but closely linked, and mixing them up is the source of most confusion when people first learn this topic.

An object is iterable if it has a method keyed by the special built-in symbol Symbol.iterator. Calling that method must return an iterator. Symbol.iterator exists as a well-known symbol (rather than a plain string like "iterator") precisely so that language features like for...of know exactly where to look, without any risk of colliding with a property you might define yourself.

An iterator, in turn, is any object that has a next() method. Each call to next() must return a plain object with two properties: value (the next item in the sequence) and done (a boolean). While done is false, the sequence keeps producing values; the moment done becomes true, iteration is considered finished and any value returned alongside it is ignored by constructs like for...of.

Arrays, strings, Maps, Sets, and the arguments object are all built-in iterables — each already implements Symbol.iterator for you behind the scenes. When you write for (const x of someArray), the engine calls someArray[Symbol.iterator]() exactly once to obtain a fresh iterator, then calls .next() on that iterator repeatedly, assigning the resulting value to x on each pass, stopping as soon as done is true.

Because the protocol is just a simple contract made of plain objects and methods, you can implement it yourself on any object — a class, an object literal, or a closure — and it instantly becomes compatible with for...of, the spread operator (...), array destructuring, Array.from(), Promise.all(), and more. This is what makes the protocol so powerful: it is a single, small interface that unlocks a huge amount of built-in language support.

Syntax

The general shape of an object that implements both protocols looks like this:

const myIterable = {
  [Symbol.iterator]() {
    // set up any starting state here
    return {
      next() {
        // must return { value, done }
      },
      return(value) {
        // optional: runs on early exit (break, throw, destructuring)
      }
    };
  }
};
Part Meaning
[Symbol.iterator]() Method every iterable must have. Called once to obtain a fresh iterator.
next() Method every iterator must have. Called repeatedly; must return { value, done }.
value The item produced by the current step of iteration.
done true once there are no more values to produce; false otherwise.
return(value) Optional. Called automatically when iteration stops early (break, an exception, or partial destructuring), so you can release resources such as open connections or timers.

Examples

Example 1: Calling an iterator manually

Every built-in iterable exposes its iterator through Symbol.iterator. Calling that method yourself reveals the raw protocol at work:

const numbers = [10, 20, 30];
const iterator = numbers[Symbol.iterator]();

console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

Output:

{ value: 10, done: false }
{ value: 20, done: false }
{ value: 30, done: false }
{ value: undefined, done: true }

Each call to next() advances an internal position by one and hands back the next element. After the third real element, the fourth call reports done: true — this is exactly what for...of does automatically under the hood, just spelled out by hand.

Example 2: Building a custom iterable class

Because the protocol is just methods, you can add it to your own classes. Here is a Range class that produces numbers between a start and end value, stepping by a configurable amount:

class Range {
  constructor(start, end, step = 1) {
    this.start = start;
    this.end = end;
    this.step = step;
  }

  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    const step = this.step;

    return {
      next() {
        if (current < end) {
          const value = current;
          current += step;
          return { value, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
}

const range = new Range(1, 10, 2);

for (const num of range) {
  console.log(num);
}

console.log([...range]);

Output:

1
3
5
7
9
[ 1, 3, 5, 7, 9 ]

Notice that range is looped over twice — once with for...of and once with the spread operator — and both produce the full sequence again. That works because [Symbol.iterator]() is called fresh each time, creating a brand-new closure with its own current variable. The Range object is a reusable iterable, even though each individual iterator it produces can only be consumed once.

Example 3: Cleaning up with return()

When a for...of loop exits early — via break, a thrown error, or destructuring only some of the values — the engine calls the iterator's optional return() method, if one exists. This is useful for closing files, clearing timers, or logging:

function createLoggingIterable(items) {
  return {
    [Symbol.iterator]() {
      let index = 0;
      return {
        next() {
          if (index < items.length) {
            return { value: items[index++], done: false };
          }
          return { value: undefined, done: true };
        },
        return(value) {
          console.log('Iteration stopped early, cleaning up...');
          return { value, done: true };
        }
      };
    }
  };
}

const items = createLoggingIterable(['a', 'b', 'c', 'd']);

for (const item of items) {
  console.log(item);
  if (item === 'b') {
    break;
  }
}

Output:

a
b
Iteration stopped early, cleaning up...

The loop prints 'a', then 'b', then hits the break statement. Before control actually leaves the loop, the engine calls iterator.return(), which logs the cleanup message. If you never implement return(), the engine simply skips this step — it is entirely optional.

Under the Hood: What for...of Actually Does

It helps to mentally expand for (const x of someIterable) { ... } into the steps the engine performs:

  1. Call someIterable[Symbol.iterator]() once, and keep the returned iterator object.
  2. Call iterator.next().
  3. If the result's done is true, stop looping immediately.
  4. Otherwise, assign the result's value to x and run the loop body.
  5. Repeat from step 2.
  6. If the loop is exited early (via break, return, or an uncaught exception inside the body), call iterator.return() if the iterator has one, so it can clean up.

This same expansion is what makes array destructuring like const [a, b] = someIterable work: the engine grabs an iterator and calls next() exactly twice, ignoring the rest (and calling return() afterward, since the iteration was left unfinished). It is also why the spread operator ([...someIterable]) works on any iterable, not just arrays — it repeatedly calls next() until done is true and collects every value into a new array.

Common Mistakes

Mistake 1: Forgetting to ever return done: true

If your custom next() method has a bug where it never returns { value: undefined, done: true }, then any for...of loop, spread, or Array.from() call over it will loop forever, since those constructs only stop when they see done become true. Always make sure every code path in next() eventually reports completion once the underlying data is exhausted. A safe pattern is to make the iterator itself iterable by returning this from Symbol.iterator, which also lets you pass the iterator directly to a for...of loop:

function countdownIterator(start) {
  let current = start;
  return {
    [Symbol.iterator]() {
      return this;
    },
    next() {
      if (current >= 0) {
        return { value: current--, done: false };
      }
      return { value: undefined, done: true };
    }
  };
}

for (const n of countdownIterator(3)) {
  console.log(n);
}

Output:

3
2
1
0

Every path through next() here terminates cleanly: once current drops below zero, every future call reports done: true instead of continuing to produce values.

Mistake 2: Reusing an already-exhausted iterator

An iterator is generally single-use: once its next() has reported done: true, calling it again typically keeps reporting done: true rather than starting over. This trips people up when they grab an iterator directly (instead of the iterable) and try to loop over it twice:

const letters = ['x', 'y', 'z'];
const iterator = letters[Symbol.iterator]();

for (const letter of iterator) {
  console.log(letter);
}

for (const letter of iterator) {
  console.log(letter);
}

console.log('second loop produced nothing because the iterator was already exhausted');

Output:

x
y
z
second loop produced nothing because the iterator was already exhausted

The fix is to keep hold of the iterable (letters) rather than a spent iterator, and loop over the iterable again — for (const letter of letters) — since each pass calls [Symbol.iterator]() fresh and gets a brand-new iterator.

Best Practices

  • Prefer for...of, spread, and destructuring over manual next() calls in application code; reach for the raw protocol only when building your own iterables or debugging.
  • When writing a custom iterable, make the object returned by Symbol.iterator itself iterable (by giving it a [Symbol.iterator]() that returns this) so it can be used both as an iterable and handed around as a stand-alone iterator.
  • Implement return() whenever your iterator holds a resource (a file handle, a timer, a database cursor) that needs cleanup if iteration stops early.
  • Keep mutable iteration state (like a position counter) inside a closure or instance field — never in a variable shared across multiple iterators, or separate loops will interfere with each other.
  • For anything beyond a very simple custom iterator, consider writing a generator function (function*) instead — it implements the whole iterator protocol for you with much less boilerplate, and is covered in the next lesson.
  • Don't use for...in to loop over iterable values — for...in enumerates property keys (and is meant for plain objects), while for...of uses the iterator protocol and is meant for sequences of values.

Practice Exercises

  • Write a custom iterable object called evensUpTo(max) that, when used in a for...of loop, yields every even number from 0 up to (but not including) max.
  • Implement a next()-based iterator manually (without a class) that walks backward through the characters of a given string, and prove it works by logging the results of an array made with [...yourIterator]. (Hint: remember an iterator must also implement Symbol.iterator, typically by returning this, if you want to use it directly with spread or for...of.)
  • Take the createLoggingIterable example from this lesson and modify it so that return() also logs how many items were actually consumed before the loop stopped.

Summary

  • An iterable is any object with a [Symbol.iterator]() method that returns an iterator.
  • An iterator is any object with a next() method that returns { value, done }.
  • for...of, the spread operator, and destructuring all work by repeatedly calling next() until done is true.
  • Arrays, strings, Maps, and Sets are all built-in iterables; you can add the same behavior to your own objects by implementing the protocol yourself.
  • Iterators are typically single-use; iterables can produce a fresh iterator on every pass.
  • An optional return() method lets an iterator clean up resources when a loop exits early.
  • Generator functions, covered next, provide a much shorter way to write objects that follow the iterator protocol.