How it works
The spec wraps an iterator in an Iterator Record with three fields: [[Iterator]], [[NextMethod]] and [[Done]]. GetIterator(obj, sync) looks up @@iterator with GetMethod, throws a TypeError if it is undefined, calls it, and requires an object back. GetIteratorDirect reads next once and stores it, so replacing iterator.next mid-loop has no effect on the running loop.
Each step is IteratorStepValue: call [[NextMethod]], require an object result (TypeError otherwise), read done, and if it is not true read value. If next throws, or done or value throws on a getter, [[Done]] is set to true. A broken iterator is never asked to close itself.
IteratorClose(record, completion) is where cleanup lives. It looks up return with GetMethod and, if present, calls it. The precedence rules are:
- If
return is missing, the original completion stands.
- If the original completion is a throw, it wins: an error from
return() is discarded.
- Otherwise an error from
return() propagates, and a non-object result throws a TypeError.
for…of calls IteratorClose whenever the body completes abruptly: break, return, an outer continue, or an exception (including one thrown while binding the loop variable). Destructuring calls it when the binding ran to the end of the pattern while [[Done]] is still false.
Edge cases
Which consumers close, using an iterator whose return() logs:
const noisy = (n) => ({
[Symbol.iterator]() {
let i = 0;
return {
next: () => (i < n ? { value: i++, done: false } : { value: undefined, done: true }),
return() { console.log('return called'); return {}; },
};
},
});
for (const x of noisy(2)) {} // exhausted: no return()
const [a, b] = noisy(2); // → return called
const [c, d, e] = noisy(2); // asked for a third, saw done: no return()
const [f] = noisy(2); // → return called
console.log([...noisy(2)]); // → [ 0, 1 ]
Exact-length destructuring ([a, b] on two elements) still closes, because the iterator has not yet reported done. That is harmless for arrays, but it matters for iterators with side effects.
Error precedence is observable:
const failing = {
[Symbol.iterator]() {
return {
next: () => ({ value: 1, done: false }),
return() { throw new Error('cleanup failed'); },
};
},
};
try {
for (const x of failing) throw new Error('body failed');
} catch (error) {
console.log(error.message); // → body failed
}
try {
for (const x of failing) break;
} catch (error) {
console.log(error.message); // → cleanup failed
}
Other edges: iterators are not required to be restartable, and once done is true later next() calls "should" also return done: true, which the spec says is not enforced. The value of the final done: true result is dropped by for…of, spread and destructuring; only yield* and manual next() calls see it. A string iterates by code point, not by UTF-16 code unit, so [...'a😀'] has two elements.
Real-world scenario
An iterable that owns a resource: a lock held for the length of a scan. return() is what makes break safe.
class Cursor {
#rows;
constructor(rows) { this.#rows = rows; }
[Symbol.iterator]() {
console.log('lock acquired');
let i = 0;
return {
next: () => (i < this.#rows.length ? { value: this.#rows[i++], done: false } : this.#release()),
return: () => this.#release(),
};
}
#release() {
console.log('lock released');
return { value: undefined, done: true };
}
}
for (const row of new Cursor(['r1', 'r2', 'r3'])) {
if (row === 'r2') break;
}
// → lock acquired
// → lock released
Without return(), the break would leave the lock held. In practice you rarely write this by hand; a generator with try…finally gets the same behaviour (see the generator question).
Trade-offs
Protocols over concrete types let one syntax work across every collection and lets you add new ones without changing consumers. The cost is that each step allocates an IteratorResult object, and that the lazy, single-pass nature surprises code that expects to re-read. For fixed-size data that you will index or measure, an array is simpler; reach for an iterable when the data is lazy, large, or has cleanup.