TypeScript Iterators

September 25, 202611 min read
Requirements:
FunctionsArraysGenerics

An iterator is any object with a next() method that hands back one value at a time and tells you when it has run out. That is the entire idea. Arrays, strings, Map, Set and every generator you have ever written are built on it, which is why for...of and the spread operator work on all of them and on nothing else.

TypeScript adds three interfaces on top — Iterator, Iterable and IterableIterator — and most of the confusion around this topic comes from not knowing which one to reach for. This page covers the protocol, those three types, how to write your own, and the two compiler settings that decide whether any of it actually compiles.

The iterator protocol

Every built-in collection exposes an iterator under a well-known symbol. Pull it out by hand and you can see the machinery:

const colourIterator = ['red', 'green'][Symbol.iterator]()
 
colourIterator.next() // { value: 'red', done: false }
colourIterator.next() // { value: 'green', done: false }
colourIterator.next() // { value: undefined, done: true }

Each call returns an IteratorResult — an object with a value and a done flag. It is a discriminated union, keyed on done, and one of the few places the standard library uses that pattern on your behalf:

[object Object]

Narrowing on done is what makes manual consumption type-safe. Check the flag and TypeScript knows value is a real T rather than T | undefined:

function drain<T>(source: Iterator<T>): T[] {
  const collected: T[] = []
  let step = source.next()
 
  while (!step.done) {
    collected.push(step.value)
    step = source.next()
  }
 
  return collected
}
 
const drained = drain(new Set([1, 2, 3]).values())
// number[]

You will almost never write that loop. It is worth seeing once, because it is exactly what for...of compiles down to.

Iterator, Iterable and IterableIterator

These three names get used interchangeably in blog posts and they are not the same thing.

An iterator is the thing with next(). An iterable is anything that can produce an iterator, via a [Symbol.iterator]() method. Stripped of their generic parameters, the two interfaces are this small:

interface MiniIterator<T> {
  next(): { value: T; done: boolean }
}
 
interface MiniIterable<T> {
  [Symbol.iterator](): MiniIterator<T>
}

IterableIterator<T> is both at once: it has next(), and its [Symbol.iterator]() returns itself. That self-reference is the useful part — it means you can for...of the same object you are calling next() on.

The separation exists because an iterable is a factory and an iterator is a position. An array is iterable and can be walked a hundred times, because each for...of asks for a fresh iterator. A generator object is an iterator that happens to also be iterable, so it has exactly one position and is exhausted after a single pass. That asymmetry explains a class of bug that otherwise looks like nonsense: spread a generator into two arrays and the second one comes back empty, while doing the same with an array works fine. Both are Iterable<T> as far as the type system is concerned — the types say nothing about how many times you get to read.

The practical rule: accept Iterable<T>, return IterableIterator<T>. Accepting an iterable makes your function work on arrays, sets, maps, strings and generators without any overloads:

function takeFirst<T>(source: Iterable<T>): T | undefined {
  for (const item of source) {
    return item
  }
  return undefined
}
 
takeFirst('typescript') // string | undefined
takeFirst(new Map([['a', 1]])) // [string, number] | undefined

Note the second result. Map iterates as key/value pairs, and TypeScript types those as a tuple rather than a loose array.

Consuming an iterable

Three syntaxes read from the protocol, and they all work on the same set of types:

const scoreBoard = new Map([
  ['ada', 12],
  ['grace', 19],
])
 
for (const [player, score] of scoreBoard) {
  console.log(`${player}: ${score}`)
}
 
const allPlayers = [...scoreBoard.keys()]
// string[]
 
const [topEntry] = scoreBoard
// [string, number]

The spread operator is the fastest way to materialise an iterable into an array, and it is the usual move when you want array methods on something that is not an array. Array destructuring reads from the iterator too, which is why you can destructure a Set or a generator, not just an array.

Plain objects are not iterable

This trips up almost everyone once. Object literals do not implement the protocol, so for...of rejects them outright:

const featureFlags = { darkMode: true, betaBanner: false }
 
// for (const flag of featureFlags) {}
// ❌ Type '{ darkMode: boolean; betaBanner: boolean; }' is not an array type
//    or a string type
 
for (const [flagName, enabled] of Object.entries(featureFlags)) {
  console.log(flagName, enabled)
}

for...in does work on objects, but it walks keys — including inherited ones — and it has no relationship to the iterator protocol at all. The two loops share three letters and nothing else. Reach for Object.entries when you want an object's contents as something iterable; it hands you an array of key/value tuples, and everything on this page applies from there.

Writing your own iterator

Implementing Iterable<T> on a class is the classic payoff. Written out longhand, you return an object with a next() that closes over some state:

class NumberRange implements Iterable<number> {
  constructor(
    private readonly from: number,
    private readonly to: number,
  ) {}
 
  [Symbol.iterator](): Iterator<number> {
    let cursor = this.from
    const ceiling = this.to
 
    return {
      next(): IteratorResult<number> {
        if (cursor > ceiling) {
          return { value: undefined, done: true }
        }
        return { value: cursor++, done: false }
      },
    }
  }
}
 
const rangeTotal = [...new NumberRange(1, 4)].reduce((sum, n) => sum + n, 0)
// 10

Because NumberRange satisfies the protocol, it works with for...of and spread with no further effort. The implements Iterable<number> clause is optional but worth keeping — it turns a typo in the method name into a compile error instead of a value that silently refuses to iterate.

Generators write the iterator for you

Hand-rolling next() gets old quickly. A generator function does the same job and keeps the state in ordinary local variables:

function* fibonacci(limitCount: number): Generator<number> {
  let previous = 0
  let upcoming = 1
 
  for (let emitted = 0; emitted < limitCount; emitted++) {
    yield previous
    ;[previous, upcoming] = [upcoming, previous + upcoming]
  }
}
 
const firstEight = [...fibonacci(8)]
// [0, 1, 1, 2, 3, 5, 8, 13]

Generator<T> extends IterableIterator<T>, so everything above applies. You can also use a generator as the [Symbol.iterator] method directly, which collapses the NumberRange example to three lines:

class LetterBag implements Iterable<string> {
  constructor(private readonly word: string) {}
 
  *[Symbol.iterator]() {
    for (const letter of this.word) {
      yield letter.toUpperCase()
    }
  }
}
 
const shouted = [...new LetterBag('ts')]
// ['T', 'S']

Laziness is the real reason to use them

Nothing in a generator runs until something pulls on it. That makes an infinite sequence perfectly safe to write, as long as the consumer stops asking:

function* naturals(): Generator<number> {
  let tally = 1
  while (true) {
    yield tally++
  }
}
 
function takeCount<T>(source: Iterable<T>, count: number): T[] {
  const picked: T[] = []
  for (const value of source) {
    if (picked.length >= count) break
    picked.push(value)
  }
  return picked
}
 
const firstFive = takeCount(naturals(), 5)
// [1, 2, 3, 4, 5]

naturals() never finishes, and that is fine — takeCount calls next() exactly five times and then walks away. Do the same thing with an array and you have to decide up front how many numbers you need. This is the whole argument for pipelines built on iterables rather than arrays: no intermediate collections, and no work done for results nobody reads.

Breaking out of a for...of is not just a jump, either. It calls the iterator's optional return() method, which is how a generator gets told to clean up:

function* readLines(source: readonly string[]): Generator<string> {
  try {
    for (const entry of source) {
      yield entry
    }
  } finally {
    console.log('reader closed')
  }
}
 
for (const line of readLines(['a', 'b', 'c'])) {
  if (line === 'b') break
}
// logs 'reader closed'

The finally block runs on the break, not at the end of the array. If your generator holds a file handle or a database cursor, that is where you release it — and it is the reason to prefer for...of over a hand-written while (!step.done) loop, which has no such guarantee.

Pick IterableIterator for return types

This is the mistake that costs people an afternoon. Annotate a function that returns a generator as Iterator<T> and you throw away the ability to iterate the result:

function evenNumbers(source: Iterable<number>): IterableIterator<number> {
  function* filterEven() {
    for (const value of source) {
      if (value % 2 === 0) {
        yield value
      }
    }
  }
  return filterEven()
}
 
const evens = [...evenNumbers([1, 2, 3, 4])]
// [2, 4]

Swap the return type to Iterator<number> and the call site breaks, even though the runtime value is unchanged:

function oddNumbers(source: Iterable<number>): Iterator<number> {
  function* filterOdd() {
    for (const value of source) {
      if (value % 2 === 1) {
        yield value
      }
    }
  }
  return filterOdd()
}
 
// const odds = [...oddNumbers([1, 2, 3])]
// ❌ Type 'Iterator<number>' must have a '[Symbol.iterator]()' method that
//    returns an iterator

The type is narrower than the value. Iterator<T> promises only next(), so spread and for...of have nothing to call. Use Iterator<T> for a parameter you are going to pump manually, and IterableIterator<T> — or Generator<T> — everywhere else.

The compiler settings that decide all of this

Two tsconfig.json fields control whether iteration compiles at all.

lib supplies the type declarations. Iterating a Map or Set needs es2015.iterable in scope; without it the collections exist but have no [Symbol.iterator], and the error you get is about for...of rather than about lib, which sends people in the wrong direction.

target decides the emitted JavaScript. At es2015 or above the protocol ships natively and for...of stays for...of. At es5 the compiler rewrites it into an index loop, which is faster but only correct for arrays and strings — and not even fully correct for strings:

const emojiSpread = [...'a🙂b']
// ['a', '🙂', 'b'] — the string iterator walks code points
 
const emojiSplit = 'a🙂b'.split('')
// ['a', '\ud83d', '\ude42', 'b'] — the index loop walks code units

downlevelIteration is the fix. It tells the compiler to emit the real protocol when targeting ES5, at the cost of a helper function per loop. If you are targeting ES5 and iterating anything other than a plain array, turn it on.

Two things make this worth knowing even on a modern target. The first is that the bug is silent: 'a🙂b' has a length of 4, so the ES5 index loop produces four items and every one of them type-checks as a string. Nothing fails until a user with an emoji in their display name files a ticket. The second is that most projects inherit target from a framework preset rather than choosing it, so the setting that governs this is usually one nobody on the team has read. If you are shipping to browsers that still need ES5, the honest answer is to set downlevelIteration once and stop thinking about it — the helper it emits is a few dozen bytes, and the class of bug it removes is the kind that survives code review.

Wrap up

An iterator is an object with next(). An iterable is an object that can hand you one. A Symbol.iterator method is all it takes to join in, and a generator writes that method for you.

The rule worth remembering is the asymmetry in signatures: take Iterable<T> so callers can pass anything, hand back IterableIterator<T> so they can keep iterating what you returned. Generic helpers built this way compose with the rest of the standard library for free — see generics if the type parameters above went past quickly, and union types for the narrowing that makes IteratorResult safe to read.

If you want the mechanics to stick, the Zip challenge asks you to walk two sequences in step at the type level, which is the same problem this protocol solves at runtime.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript iterators knowledge to the test with these related challenges.

#14First of Array
Easy
#15Last of Array
Medium
#459Flatten
Medium
#4499Chunk
Medium
#4471Zip
Medium

Related Concepts

Concepts that build on or relate to typescript iterators.

TypeScript GenericsTypeScript TuplesUnion TypesTypeScript ClassesThe JavaScript Spread Operator in TypeScript