Iterator Pattern: Sequential Access
The Iterator pattern provides a way to access elements of a collection sequentially without exposing its underlying representation. Whether you're traversing a linked list, a binary tree, or a graph,...
Key Insights
- The Iterator pattern decouples traversal logic from collection internals, allowing you to change data structures without breaking client code
- Modern languages have built-in iterator protocols (Java’s Iterable, Python’s
__iter__, JavaScript’s Symbol.iterator) that you should leverage before building custom solutions - Custom iterators shine when you need filtered traversal, tree walking, or cursor-based pagination—not for simple list access
Introduction to the Iterator Pattern
The Iterator pattern provides a way to access elements of a collection sequentially without exposing its underlying representation. Whether you’re traversing a linked list, a binary tree, or a graph, client code sees the same interface: “give me the next element.”
The Gang of Four formalized this pattern in 1994, but the concept predates their book. The core insight remains relevant: collections and traversal are separate concerns. A playlist shouldn’t know whether you’re iterating forward, backward, or skipping every other song. That’s the iterator’s job.
This pattern appears everywhere in modern codebases, often invisibly. Every for...of loop in JavaScript, every for item in collection in Python, and every enhanced for-loop in Java uses iterators under the hood.
The Problem It Solves
Consider a music library with songs organized in a tree structure—genres containing artists containing albums containing tracks. Here’s what happens when client code knows too much about the collection:
class MusicLibrary {
genres: Map<string, Map<string, Map<string, Song[]>>> = new Map();
// Client code that's tightly coupled to the structure
findAllSongs(): Song[] {
const songs: Song[] = [];
for (const [genreName, artists] of this.genres) {
for (const [artistName, albums] of artists) {
for (const [albumName, tracks] of albums) {
for (const song of tracks) {
songs.push(song);
}
}
}
}
return songs;
}
}
// Every consumer repeats this nested traversal
function countExplicitSongs(library: MusicLibrary): number {
let count = 0;
for (const [_, artists] of library.genres) {
for (const [_, albums] of artists) {
for (const [_, tracks] of albums) {
for (const song of tracks) {
if (song.explicit) count++;
}
}
}
}
return count;
}
This code has three problems. First, every client must understand the four-level nesting. Second, changing the structure (say, adding a “year” level) breaks all clients. Third, the traversal logic is duplicated everywhere.
The Iterator pattern eliminates all three issues by encapsulating traversal in a dedicated object.
Pattern Structure and Components
The pattern involves four participants:
Iterator defines the interface for accessing and traversing elements. At minimum: hasNext() and next(). Optionally: remove(), reset(), or current().
ConcreteIterator implements the Iterator interface and tracks the current position within the collection.
Aggregate (or Iterable) defines an interface for creating an Iterator object.
ConcreteAggregate implements the Aggregate interface and returns an instance of the appropriate ConcreteIterator.
Here’s the contract in TypeScript:
interface Iterator<T> {
hasNext(): boolean;
next(): T;
reset(): void;
}
interface Iterable<T> {
createIterator(): Iterator<T>;
}
And the equivalent in Java:
public interface Iterator<T> {
boolean hasNext();
T next();
default void remove() {
throw new UnsupportedOperationException();
}
}
public interface Iterable<T> {
Iterator<T> iterator();
}
The aggregate creates iterators; iterators traverse the aggregate. Neither exposes implementation details to clients.
Implementation Walkthrough
Let’s build a playlist system with a custom iterator. The playlist stores songs internally but exposes a clean iteration interface.
interface Song {
id: string;
title: string;
artist: string;
durationSeconds: number;
}
class PlaylistIterator implements Iterator<Song> {
private playlist: Playlist;
private currentIndex: number = 0;
constructor(playlist: Playlist) {
this.playlist = playlist;
}
hasNext(): boolean {
return this.currentIndex < this.playlist.getCount();
}
next(): Song {
if (!this.hasNext()) {
throw new Error('No more elements');
}
return this.playlist.getSongAt(this.currentIndex++);
}
reset(): void {
this.currentIndex = 0;
}
}
class Playlist implements Iterable<Song> {
private songs: Song[] = [];
private name: string;
constructor(name: string) {
this.name = name;
}
addSong(song: Song): void {
this.songs.push(song);
}
removeSong(id: string): void {
this.songs = this.songs.filter(s => s.id !== id);
}
// Package-private accessors for the iterator
getCount(): number {
return this.songs.length;
}
getSongAt(index: number): Song {
return this.songs[index];
}
// Factory method - the key to the pattern
createIterator(): Iterator<Song> {
return new PlaylistIterator(this);
}
}
Client code now looks like this:
function printPlaylist(playlist: Playlist): void {
const iterator = playlist.createIterator();
while (iterator.hasNext()) {
const song = iterator.next();
console.log(`${song.title} - ${song.artist}`);
}
}
function getTotalDuration(playlist: Playlist): number {
const iterator = playlist.createIterator();
let total = 0;
while (iterator.hasNext()) {
total += iterator.next().durationSeconds;
}
return total;
}
The client doesn’t know if songs are stored in an array, linked list, or database. It doesn’t care. Change the internal structure, and client code keeps working.
Built-in Language Support
Modern languages have iterator protocols baked in. Use them.
Python
Python’s iterator protocol requires __iter__() returning an iterator and __next__() returning the next element or raising StopIteration:
class Playlist:
def __init__(self, name: str):
self.name = name
self._songs: list[Song] = []
def add_song(self, song: Song) -> None:
self._songs.append(song)
def __iter__(self):
# Generator syntax makes this trivial
for song in self._songs:
yield song
def __len__(self):
return len(self._songs)
# Now it works with for loops, list(), etc.
playlist = Playlist("Road Trip")
playlist.add_song(Song("Highway to Hell", "AC/DC"))
playlist.add_song(Song("Life is a Highway", "Tom Cochrane"))
for song in playlist:
print(f"{song.title} - {song.artist}")
# Also works with comprehensions
titles = [song.title for song in playlist]
JavaScript
JavaScript uses Symbol.iterator to make objects iterable:
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 { done: true };
}
};
}
}
// Works with for...of, spread, Array.from
const range = new Range(1, 10, 2);
console.log([...range]); // [1, 3, 5, 7, 9]
for (const num of new Range(5, 8)) {
console.log(num); // 5, 6, 7, 8
}
Implementing these protocols gives you interoperability with the entire language ecosystem—destructuring, spread operators, Array.from(), and every library that expects iterables.
Variations and Advanced Patterns
Filtered Iterators
Wrap an existing iterator to skip elements that don’t match a predicate:
class FilteredIterator<T> implements Iterator<T> {
private source: Iterator<T>;
private predicate: (item: T) => boolean;
private nextItem: T | null = null;
private hasNextItem: boolean = false;
constructor(source: Iterator<T>, predicate: (item: T) => boolean) {
this.source = source;
this.predicate = predicate;
this.advance();
}
private advance(): void {
while (this.source.hasNext()) {
const item = this.source.next();
if (this.predicate(item)) {
this.nextItem = item;
this.hasNextItem = true;
return;
}
}
this.hasNextItem = false;
}
hasNext(): boolean {
return this.hasNextItem;
}
next(): T {
if (!this.hasNextItem) {
throw new Error('No more elements');
}
const item = this.nextItem!;
this.advance();
return item;
}
reset(): void {
this.source.reset();
this.advance();
}
}
// Usage: iterate only explicit songs
const explicitIterator = new FilteredIterator(
playlist.createIterator(),
song => song.explicit
);
Cursor-Based Pagination
Real-world APIs use iterators for pagination. The cursor is just an iterator position serialized for network transport:
interface PaginatedResult<T> {
items: T[];
nextCursor: string | null;
}
class PaginatedIterator<T> {
private fetchPage: (cursor: string | null) => Promise<PaginatedResult<T>>;
private currentPage: T[] = [];
private pageIndex: number = 0;
private nextCursor: string | null = null;
private initialized: boolean = false;
constructor(fetchPage: (cursor: string | null) => Promise<PaginatedResult<T>>) {
this.fetchPage = fetchPage;
}
async hasNext(): Promise<boolean> {
if (!this.initialized) {
await this.loadNextPage();
this.initialized = true;
}
return this.pageIndex < this.currentPage.length || this.nextCursor !== null;
}
async next(): Promise<T> {
if (this.pageIndex >= this.currentPage.length && this.nextCursor) {
await this.loadNextPage();
}
return this.currentPage[this.pageIndex++];
}
private async loadNextPage(): Promise<void> {
const result = await this.fetchPage(this.nextCursor);
this.currentPage = result.items;
this.nextCursor = result.nextCursor;
this.pageIndex = 0;
}
}
Trade-offs and When to Use
Benefits:
- Single responsibility: collections store, iterators traverse
- Multiple simultaneous iterations over the same collection
- Uniform interface across different data structures
- Lazy evaluation—process elements one at a time without loading everything into memory
Drawbacks:
- Overhead for simple sequential access on arrays
- Additional classes to maintain
- Potential for stale iterators if collection mutates during traversal
Use custom iterators when:
- Your data structure isn’t a simple array or list
- You need filtered or transformed iteration
- You’re implementing pagination or streaming
- Multiple traversal algorithms exist for the same structure (depth-first vs. breadth-first)
Skip custom iterators when:
- A simple array with built-in methods suffices
- You’re only iterating once in one place
- The overhead isn’t justified by the abstraction benefit
The Iterator pattern is foundational—so foundational that languages absorbed it. Understand the mechanics, leverage built-in protocols, and reach for custom implementations only when they provide clear value. That’s the pragmatic approach.