Skip to content
academia.sh

Lesson 14 / 17

Iterators

The iteration protocol's two contracts, making a type traversable through its prototype, the distinction between iterator and iterable, cleanup on early exit, and lazy production.

Contents

In the previous lesson, a for...of attempt on WeakMap threw an error, while the same loop works on Map, Set, an array, and a string. The difference is not a list of types: the for...of loop, the spread operator, destructuring, and Array.from all look at a single contract.

The iteration protocol consists of two contracts. If an object offers a method named [Symbol.iterator], it is iterable; that method has to return an iterator offering next. The lesson first sets up the contract by hand, then binds the measurement series to this contract.

The next Contract

An iterator is an ordinary object with a single method. A next call returns a two-field object: the next value, and whether the sequence has ended.

function measurementIterator(values) {
  let index = 0;
  return {
    next() {
      if (index >= values.length) return { value: undefined, done: true };
      const value = values[index];
      index += 1;
      return { value, done: false };
    },
  };
}

const iterator = measurementIterator([21.4, 19.8, 25.1]);

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

const second = measurementIterator([21.4, 19.8]);
let step = second.next();
const collected = [];
while (!step.done) {
  collected.push(step.value);
  step = second.next();
}
console.log(collected.join(","));
console.log(typeof iterator[Symbol.iterator]);
{"value":21.4,"done":false}
{"value":19.8,"done":false}
{"value":25.1,"done":false}
{"done":true}
21.4,19.8
undefined

Position information is held in the index variable and is invisible from outside — the pattern from the Closures lesson works here too. The value field not appearing in the fourth output is a result of serialization: fields with an undefined value do not enter JSON.stringify output.

The while loop is a for...of loop written by hand. The last line tells you why this object cannot be used with for...of: it offers next but not [Symbol.iterator], so it is an iterator but not iterable.

Making a Type Iterable

The second contract is an object offering a [Symbol.iterator] method. When the method is written in a class body, it stands on the prototype and all instances become iterable.

class MeasurementSeries {
  constructor(sensor, values) {
    this.sensor = sensor;
    this.values = values;
  }

  [Symbol.iterator]() {
    let index = 0;
    const values = this.values;
    return {
      next() {
        if (index >= values.length) return { value: undefined, done: true };
        const value = values[index];
        index += 1;
        return { value, done: false };
      },
    };
  }
}

const series = new MeasurementSeries("S-01", [21.4, 19.8, 25.1]);

const collected = [];
for (const value of series) collected.push(value);
console.log(collected.join(","));

console.log([...series].join(","));
console.log(Array.from(series).length);

const [first, second] = series;
console.log(`${first} ${second}`);

console.log(Math.max(...series));
console.log(Object.hasOwn(series, Symbol.iterator));
console.log(Object.hasOwn(MeasurementSeries.prototype, Symbol.iterator));
console.log(Object.keys(series).join(","));
console.log(JSON.stringify(series));
21.4,19.8,25.1
21.4,19.8,25.1
3
21.4 19.8
25.1
false
true
sensor,values
{"sensor":"S-01","values":[21.4,19.8,25.1]}

One method was added; five separate language features started working. for...of, spread, Array.from, array destructuring, and argument spreading all use the same contract.

The sixth and seventh lines tie back to this lesson’s axis: the method stands not on the instance, but on MeasurementSeries.prototype. Iterability is not a flag belonging to the object, it is a method found on the chain. This is why making a type iterable afterward is possible by adding this method to its prototype.

Because it is symbol-keyed, it does not appear in Object.keys output and does not enter serialization; this property of symbol keys, covered in the Property Descriptors lesson, keeps protocol methods separate from data fields.

The Iterator–Iterable Distinction

The two contracts most often merge in the same object. Built-in iterators offer a [Symbol.iterator] method that returns themselves, which is what makes them both iterator and iterable.

const values = [21.4, 19.8, 25.1];

const iterator = values[Symbol.iterator]();
console.log(typeof iterator.next);
console.log(typeof iterator[Symbol.iterator]);
console.log(iterator[Symbol.iterator]() === iterator);

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

const remaining = [];
for (const value of iterator) remaining.push(value);
console.log(remaining.join(","));

const emptyReturn = [];
for (const value of iterator) emptyReturn.push(value);
console.log(emptyReturn.length);

const firstPass = [];
for (const value of values) firstPass.push(value);
const secondPass = [];
for (const value of values) secondPass.push(value);
console.log(firstPass.length === secondPass.length);

const text = "measurement";
console.log([...text].join("-"));
const map = new Map([["S-01", 21.4]]);
console.log([...map][0].join("="));
function
function
true
{"value":21.4,"done":false}
19.8,25.1
0
true
m-e-a-s-u-r-e-m-e-n-t
S-01=21.4

The practical consequence of the distinction is exhaustion. Traversing an iterator consumes it: the first for...of reads the remaining two values, the second finds nothing. An iterable object, on the other hand, produces a new iterator on every traversal, which is why it can be traversed over and over.

This explains why it matters whether an iterable object or an iterator is given to a function. If an iterator is given to a function that needs to traverse it twice, the second pass returns empty; no error message is produced either.

The last two lines show how widespread the protocol is. Traversing a string gives code points, not code units — the distinction introduced in the Character Encodings lesson of the How Computers Work course is observable here. Traversing a Map gives key–value pairs.

Early Exit and Cleanup

When a for...of loop is ended with break, or an error is thrown, the protocol calls the iterator’s return method. This is the reserved point for closing open resources.

const log = [];

function resourceIterator(values) {
  let index = 0;
  let closed = false;
  return {
    [Symbol.iterator]() {
      return this;
    },
    next() {
      if (closed || index >= values.length) return { value: undefined, done: true };
      const value = values[index];
      index += 1;
      return { value, done: false };
    },
    return(value) {
      closed = true;
      log.push(`resource closed, read=${index}`);
      return { value, done: true };
    },
  };
}

const resource = resourceIterator([21.4, 19.8, 25.1, 18.2]);

for (const value of resource) {
  if (value > 20 && value < 22) {
    console.log(`found: ${value}`);
    break;
  }
}

console.log(log.join(" | "));
console.log(JSON.stringify(resource.next()));

const secondResource = resourceIterator([21.4, 19.8]);
const [singleValue] = secondResource;
console.log(singleValue);
console.log(log.length);
found: 21.4
resource closed, read=1
{"done":true}
21.4
2

A break statement does not just break the loop, it also notifies the iterator. A closed iterator answers as finished on subsequent next calls. The last section shows the same call is made during array destructuring too: once the requested number of values is taken, the resource is closed.

This behavior matters for iterators bound to resources like file and network streams; early exit does not leave the resource open.

Lazy Production

The protocol’s last property is that values are produced as requested. Even if the source is an array, the computation done on each value only runs when that value is read.

let computeCount = 0;

function calibratedSeries(rawValues, offset) {
  return {
    [Symbol.iterator]() {
      let index = 0;
      return {
        [Symbol.iterator]() {
          return this;
        },
        next() {
          if (index >= rawValues.length) return { value: undefined, done: true };
          computeCount += 1;
          const value = rawValues[index] + offset;
          index += 1;
          return { value: Number(value.toFixed(2)), done: false };
        },
      };
    },
  };
}

const series = calibratedSeries([21.4, 19.8, 25.1, 18.2, 30.0], -0.4);

console.log(computeCount);

let firstAboveThreshold;
for (const value of series) {
  if (value > 24) {
    firstAboveThreshold = value;
    break;
  }
}

console.log(firstAboveThreshold);
console.log(computeCount);

const all = [...series];
console.log(all.join(","));
console.log(computeCount);
0
24.7
3
21,19.4,24.7,17.8,29.6
8

No computation has been done when the series is set up. Three computations run until the first value above the threshold is found; the remaining two values are never computed. When the same series is traversed from the start again, five more computations run and the total climbs to eight.

If the same job were written with map, all five computations would run up front. This is the lazy evaluation introduced in the Introduction to Functional Programming lesson of the Programming Fundamentals course, and it is the only workable approach for large or endless sequences.

The cost of writing the protocol by hand is also visible in this example: position, the end condition, and the self-returning [Symbol.iterator] method are repeated every time. The next lesson introduces a syntax that eliminates this repetition.

Summary

  • The iteration protocol consists of two contracts: an iterable object offers [Symbol.iterator], an iterator offers next.
  • for...of, spread, destructuring, Array.from, and argument spreading all use the same contract; adding a single method makes all of them work.
  • [Symbol.iterator] written in a class body stands on the prototype; iterability is a method found on the chain.
  • An iterator gets exhausted, an iterable object produces a new iterator on every traversal; this distinction is decisive wherever something needs to be traversed twice.
  • break and early destructuring call the iterator’s return method, allowing for resource cleanup.
  • Values are produced as requested; an unread value is never computed at all.

Next Step

The same three pieces kept repeating in hand-written iterators: the variable holding position, the end condition, and the self-returning [Symbol.iterator] method. The language offers a function type that produces all three itself — functions that can pause their execution and resume where they left off. The next lesson takes up generators and reduces this lesson’s lazy series to a few lines.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close