Skip to content
Commitwise
Menu
Design Patterns

Iterator

Walk through a collection without knowing how it's stored, make your own types work with for loops, avoid changing a collection while iterating over it, and use lazy sequences, generators and async iteration for paged APIs, in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The app keeps the last 50 searches in a ring buffer: a fixed array where new entries overwrite the oldest, and an index that says where the newest one is. The search screen needs them newest first. The first version exposed the array and the index, and the screen did the arithmetic itself. So did the analytics code. So did the “clear old searches” job, which got it wrong by one and skipped the oldest entry.

An Iterator gives the outside world one way to walk through a collection, element by element, without knowing how it’s stored. The ring buffer knows its own arithmetic; everyone else writes for search in recentSearches.

New to some of these? Open what you need, or skip ahead.

Basics:What is a for loop over a collection?

for item in list (Swift, Kotlin), for (var item : list) (Java) and for (const item of list) (TypeScript) run the loop body once for each element. Behind the scenes, the language asks the collection for an iterator and calls “give me the next element” until there are none left. The Collections and functional operations tutorial covers loops, map and filter.

Part Not started

Making your own types iterable

A bookmark in a book is a tiny iterator: it remembers where you are, and “next” means turn the page. Two people reading the same book each have their own bookmark, so they don’t disturb each other. And you don’t need to know how the book was bound to read it page by page.

In code, an iterator is an object with one job: give the next element, or say there are no more. The collection’s job is to create a new iterator whenever someone wants to loop over it. Every language has a standard shape for this, and when your class follows it, for loops, map, filter and friends just work.

Iterator provides sequential access to an aggregate’s elements without exposing its representation, and separates traversal state from the collection, so several traversals can run independently. Every modern language builds it in: Swift Sequence/IteratorProtocol, Kotlin Iterable/Iterator (and operator fun iterator()), Java Iterable/Iterator (plus Spliterator for streams), TypeScript’s iteration protocol ([Symbol.iterator] and generator functions). Implementing the protocol is nearly always better than exposing internals or inventing getItem(i)/count() APIs: you get the language’s loops and the standard library’s operators for free, and you can change the storage later.

struct RecentSearches: Sequence {
    private var buffer: [String?]
    private var newest = -1            // index of the newest entry
    private(set) var count = 0

    init(capacity: Int) { buffer = Array(repeating: nil, count: capacity) }

    mutating func add(_ query: String) {
        newest = (newest + 1) % buffer.count
        buffer[newest] = query
        count = min(count + 1, buffer.count)
    }

    // Each call creates a fresh iterator with its own position
    func makeIterator() -> AnyIterator<String> {
        var step = 0
        return AnyIterator {
            guard step < count else { return nil }
            let index = (newest - step + buffer.count) % buffer.count
            step += 1
            return buffer[index]
        }
    }
}

for query in recent { print(query) }                // newest first
let suggestions = recent.filter { $0.hasPrefix(text) }.prefix(5)
class RecentSearches(capacity: Int) : Iterable<String> {
    private val buffer = arrayOfNulls<String>(capacity)
    private var newest = -1
    var size = 0
        private set

    fun add(query: String) {
        newest = (newest + 1) % buffer.size
        buffer[newest] = query
        size = minOf(size + 1, buffer.size)
    }

    // Each call creates a fresh iterator with its own position
    override fun iterator(): Iterator<String> = object : Iterator<String> {
        private var step = 0
        override fun hasNext() = step < size
        override fun next(): String {
            if (!hasNext()) throw NoSuchElementException()
            val index = (newest - step + buffer.size) % buffer.size
            step++
            return buffer[index]!!
        }
    }
}

for (query in recent) println(query)                 // newest first
val suggestions = recent.filter { it.startsWith(text) }.take(5)
public final class RecentSearches implements Iterable<String> {
    private final String[] buffer;
    private int newest = -1;
    private int size = 0;

    public RecentSearches(int capacity) { buffer = new String[capacity]; }

    public void add(String query) {
        newest = (newest + 1) % buffer.length;
        buffer[newest] = query;
        size = Math.min(size + 1, buffer.length);
    }

    // Each call creates a fresh iterator with its own position
    @Override
    public Iterator<String> iterator() {
        return new Iterator<>() {
            private int step = 0;
            public boolean hasNext() { return step < size; }
            public String next() {
                if (!hasNext()) throw new NoSuchElementException();
                int index = (newest - step + buffer.length) % buffer.length;
                step++;
                return buffer[index];
            }
        };
    }
}

for (String query : recent) System.out.println(query);   // newest first
class RecentSearches implements Iterable<string> {
  private readonly buffer: (string | undefined)[];
  private newest = -1;
  private count = 0;

  constructor(capacity: number) { this.buffer = new Array(capacity); }

  add(query: string) {
    this.newest = (this.newest + 1) % this.buffer.length;
    this.buffer[this.newest] = query;
    this.count = Math.min(this.count + 1, this.buffer.length);
  }

  // A generator method: each call returns a fresh iterator
  *[Symbol.iterator](): Iterator<string> {
    for (let step = 0; step < this.count; step++) {
      const index = (this.newest - step + this.buffer.length) % this.buffer.length;
      yield this.buffer[index]!;
    }
  }
}

for (const query of recent) console.log(query);       // newest first
const all = [...recent];                               // spread works too
  • The position (step) lives in the iterator, not in the collection. Two loops over recent at the same time each have their own position.
  • The index arithmetic is written once, inside the class. If the storage changes later (a linked list, a database table), the loops outside don’t change.
  • Following the language’s protocol gives you the language’s tools: for loops, filter, prefix/take, spreading into an array.
  • In TypeScript, a generator (function* with yield) writes the iterator for you: each yield hands out one element and pauses until the loop asks for the next one.
Spot the bugClearing expired drafts crashes (or skips some)

Tap the line with the bug.

var drafts: [Draft] = loadDrafts()

for i in 0..<drafts.count {
    if drafts[i].isExpired {
        drafts.remove(at: i)
    }
}
val drafts: MutableList<Draft> = loadDrafts()

for (draft in drafts) {
    if (draft.isExpired) {
        drafts.remove(draft)
    }
}
List<Draft> drafts = loadDrafts();

for (Draft draft : drafts) {
    if (draft.isExpired()) {
        drafts.remove(draft);
    }
}
const drafts: Draft[] = loadDrafts();

drafts.forEach((draft, i) => {
  if (draft.isExpired) {
    drafts.splice(i, 1);
  }
});

Predict the output

Two loops run nested over the same RecentSearches with 3 entries: for a in recent { for b in recent { count += 1 } }. What is count?

  • Sequential access without exposing representation; traversal state lives in the iterator, so traversals are independent.
  • Built into every language: Swift Sequence/IteratorProtocol, Kotlin/Java Iterable/Iterator, TS [Symbol.iterator] and generators.
  • Implementing the protocol buys loops and standard operators and keeps storage private.
  • Never mutate during iteration: Java/Kotlin fail fast (ConcurrentModificationException), Swift crashes on stale indices, TS silently skips. Use removeAll/removeIf/filter or iterator.remove().

Check this part

  1. Where does the current position of a loop live?

  2. What's the safe way to remove expired items from a list?

  3. TypeScript: what happens when you splice during forEach?

An iterator hands out elements one at a time and says when it's done; the collection creates a fresh one for each loop. Every language has a protocol for this, so implementing it makes your type work with for loops and collection operations. Don't change a collection while iterating it.

Part Not started

Lazy sequences and paged APIs

An iterator doesn’t need all its elements up front. It only has to answer “what’s next?” when asked. So it can compute the next element at that moment, read the next line of a big file, or fetch the next page from a server. This is called being lazy.

That’s very useful for APIs that return results in pages. Instead of every screen writing “load page 1, then page 2 while there’s a next cursor…”, you write that loop once, inside an iterator, and callers just loop over all orders. If they stop early (because they found what they needed), the remaining pages are never requested.

Lazy iteration decouples production from consumption: elements are produced on demand, so pipelines over large or infinite sources use constant memory and short-circuiting operations (first, take, any) stop the work early. Each platform has an async form for I/O-bound sources: Swift AsyncSequence (via AsyncThrowingStream or a custom AsyncIteratorProtocol), Kotlin Flow (cold, sequential, with cancellation), Java blocking Iterator/Stream over a paging client (or reactive Flux), and TypeScript async function* with for await. Two properties matter in production: single use (many lazy iterables can’t be restarted: Java streams, generator objects, Sequences built with sequence {} over a consumable source), and cancellation (stopping the loop must stop the fetching and release resources).

struct OrdersPage: Decodable { let orders: [Order]; let nextCursor: String? }

func allOrders(api: OrdersApi) -> AsyncThrowingStream<Order, Error> {
    AsyncThrowingStream { continuation in
        let task = Task {
            var cursor: String? = nil
            repeat {
                let page = try await api.orders(after: cursor)
                for order in page.orders { continuation.yield(order) }
                cursor = page.nextCursor
            } while cursor != nil && !Task.isCancelled
            continuation.finish()
        }
        continuation.onTermination = { _ in task.cancel() }   // the loop stopped: stop fetching
    }
}

for try await order in allOrders(api: api) {
    if order.id == wanted { break }                           // later pages are never fetched
}
data class OrdersPage(val orders: List<Order>, val nextCursor: String?)

fun allOrders(api: OrdersApi): Flow<Order> = flow {
    var cursor: String? = null
    do {
        val page = api.orders(after = cursor)                  // suspend function
        page.orders.forEach { emit(it) }
        cursor = page.nextCursor
    } while (cursor != null)
}

val match = allOrders(api).firstOrNull { it.id == wanted }    // cancels the flow when found
public record OrdersPage(List<Order> orders, String nextCursor) {}

// A lazy Iterator that fetches the next page only when the current one is used up
public final class AllOrders implements Iterable<Order> {
    private final OrdersApi api;
    public AllOrders(OrdersApi api) { this.api = api; }

    @Override
    public Iterator<Order> iterator() {
        return new Iterator<>() {
            private Iterator<Order> current = Collections.emptyIterator();
            private String cursor = null;
            private boolean lastPage = false;

            public boolean hasNext() {
                while (!current.hasNext() && !lastPage) {
                    OrdersPage page = api.orders(cursor);
                    current = page.orders().iterator();
                    cursor = page.nextCursor();
                    lastPage = cursor == null;
                }
                return current.hasNext();
            }

            public Order next() {
                if (!hasNext()) throw new NoSuchElementException();
                return current.next();
            }
        };
    }
}

Optional<Order> match = StreamSupport.stream(new AllOrders(api).spliterator(), false)
        .filter(o -> o.id().equals(wanted))
        .findFirst();                                         // stops fetching when found
interface OrdersPage { orders: Order[]; nextCursor: string | null }

async function* allOrders(api: OrdersApi): AsyncGenerator<Order> {
  let cursor: string | null = null;
  do {
    const page: OrdersPage = await api.orders(cursor);
    yield* page.orders;
    cursor = page.nextCursor;
  } while (cursor !== null);
}

for await (const order of allOrders(api)) {
  if (order.id === wanted) break;                             // later pages are never fetched
}

Production puzzles

Predict the output

TypeScript: const orders = allOrders(api); then two loops run one after the other: first for await (const o of orders) to count them, then for await (const o of orders) to sum totals. What does the second loop see?

Predict the output

Kotlin: val pages = sequence { var c: String? = null; do { val p = api.ordersBlocking(c); yieldAll(p.orders); c = p.nextCursor } while (c != null) }. The UI code calls pages.count() and then pages.take(20).toList(). How many times is page 1 fetched?

Cancellation and resource cleanup

When the consumer stops early, the producer must stop too, and release what it opened:

  • Swift: onTermination on the stream’s continuation cancels the producing task; inside a custom AsyncIteratorProtocol, check Task.isCancelled.
  • Kotlin: firstOrNull, take and a cancelled collecting coroutine cancel the flow {} block at its next suspension point; use try { … } finally { … } or onCompletion to close cursors or files.
  • Java: a plain Iterator has no close; if it holds a database cursor or a file, make it AutoCloseable and use it with try-with-resources, or expose a Stream with onClose and close the stream.
  • TypeScript: break in a for await calls the generator’s return(), which runs its finally blocks; put cleanup there.

Java note

Since Java 21, SequencedCollection adds reversed(), getFirst() and getLast() to lists, deques and linked sets, so many “iterate backwards” helpers are no longer needed. For your own types, implementing Iterable is still the way into the enhanced for loop, and spliterator() (with the right characteristics, such as ORDERED or SIZED) is the way into efficient streams.

Exerciseeasy

Lines of a big log file

A support tool needs to find the first line containing an error code in a 2 GB log file. Version one reads the whole file into a list of lines and then searches it, and the tool runs out of memory. Describe a better approach using iterators, and what should happen when the line is found.

›

Does the tool ever need more than one line at a time?

SolutionTry it yourself first

Read the file lazily, one line at a time, and stop at the first match:

  • Swift: for try await line in url.lines and break on the match.
  • Kotlin: file.useLines { lines -> lines.firstOrNull { code in it } }.
  • Java: try (var lines = Files.lines(path)) { lines.filter(l -> l.contains(code)).findFirst(); }.
  • TypeScript (Node): for await (const line of readline.createInterface({ input: fs.createReadStream(path) })).

Memory use stays at about one line. When the match is found the loop stops, and the file must be closed: useLines and try-with-resources do that automatically, and breaking out of for await closes the stream.

  • Lazy iterators produce on demand: constant memory, short-circuiting stops work early.
  • Async forms for paging: Swift AsyncThrowingStream, Kotlin Flow, Java lazy Iterator/Stream, TS async function* with for await.
  • Single use vs re-iterable: generator objects and Java streams are one-shot; Kotlin sequence {} and cold Flow re-run (and re-fetch) per terminal operation.
  • Propagate cancellation and close resources: onTermination, finally/onCompletion, AutoCloseable streams, generator return().

Check this part

  1. What does lazy mean for an iterator?

  2. You loop twice over the same TypeScript generator object. What does the second loop see?

  3. Why should stopping a paged iteration cancel the producer?

Iterators can be lazy: they compute each element only when asked, so long or endless sequences and paged APIs can be consumed one item at a time. Async iterators (AsyncSequence, Flow, async generators) hide paging behind a loop. Many lazy iterators can be consumed only once.

Key takeaways

  • An iterator gives elements one at a time without showing how they’re stored.
  • Implement your language’s iteration protocol so for loops just work.
  • Never change a list while looping over it; use removeAll, removeIf or filter.
  • Lazy iterators fetch or compute only what the loop actually uses.
  • Built-in protocols give independent traversals and standard operators.
  • Mutation during iteration fails fast, crashes or skips silently, depending on the language.
  • Async iteration hides paging; mind single use vs re-run semantics.
  • Propagate cancellation and close resources when consumers stop early.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.