Programming

Coding Smarter, Not Harder: Why the Iterator Pattern Is Your New Best Friend

The collection was complex. The client code was a mess. Each data structure required different code to traverse it. Arrays are needed for loops. Lists needed while loops. Trees needed recursion. The code grew tangled with traversal logic.

Then came the Iterator Pattern. Simplicity through abstraction.

The Heart of Iteration

The Iterator Pattern provides a way to access elements of a collection sequentially without exposing the underlying structure.

It separates two critical concerns:

  1. How to store data
  2. How to access data

This separation gives you freedom. Change how you store data without changing how you access it.

The Problem: Different Collections, Different Access Methods

Let’s look at three common ways to store data, and how messy it gets to access each one:

// 1. Simple array of names
const namesArray = ["Emma", "James", "Sarah", "Michael"];

// 2. Linked list of names
class ListNode {
  constructor(public name: string, public next: ListNode | null = null) {}
}

class NameList {
  head: ListNode | null = null;
  
  addName(name: string) {
    const newNode = new ListNode(name);
    
    // If list is empty, make this the first node
    if (!this.head) {
      this.head = newNode;
      return;
    }
    
    // Otherwise find the end and add there
    let current = this.head;
    while (current.next) {
      current = current.next;
    }
    current.next = newNode;
  }
}

// Create our linked list
const namesList = new NameList();
namesList.addName("Emma");
namesList.addName("James");
namesList.addName("Sarah");
namesList.addName("Michael");

// 3. Binary tree of names
class TreeNode {
  constructor(
    public name: string,
    public left: TreeNode | null = null,
    public right: TreeNode | null = null
  ) {}
}

class NameTree {
  root: TreeNode | null = null;
  
  // Add a name to the tree
  addName(name: string) {
    // Simple insertion logic for example
    const newNode = new TreeNode(name);
    
    if (!this.root) {
      this.root = newNode;
      return;
    }
    
    // Very simple insertion - not a balanced tree
    this.insertNode(this.root, newNode);
  }
  
  private insertNode(node: TreeNode, newNode: TreeNode) {
    // Go left if the new name comes before current node
    if (newNode.name < node.name) {
      if (node.left === null) {
        node.left = newNode;
      } else {
        this.insertNode(node.left, newNode);
      }
    } else {
      // Go right if the new name comes after current node
      if (node.right === null) {
        node.right = newNode;
      } else {
        this.insertNode(node.right, newNode);
      }
    }
  }
}

// Create our tree
const namesTree = new NameTree();
namesTree.addName("Emma");
namesTree.addName("James");
namesTree.addName("Sarah");
namesTree.addName("Michael");

Now, let’s say we want to print all the names. We need three completely different approaches:

// 1. Print names from the array - easy with a for loop
console.log("Names from array:");
for (let i = 0; i < namesArray.length; i++) {
  console.log(namesArray[i]);
}

// 2. Print names from the linked list - need to follow the chain of nodes
console.log("Names from linked list:");
let currentNode = namesList.head;
while (currentNode) {
  console.log(currentNode.name);
  currentNode = currentNode.next;
}

// 3. Print names from the tree - need recursion for in-order traversal
console.log("Names from tree (alphabetical order):");
function printTreeNames(node: TreeNode | null) {
  if (node === null) return;
  
  // In-order traversal: left, current, right
  printTreeNames(node.left);
  console.log(node.name);
  printTreeNames(node.right);
}
printTreeNames(namesTree.root);

This is a mess! Three different ways to do the same thing: iterate through a collection.

The Solution: One Iterator to Rule Them All

The Iterator Pattern creates a standard interface for moving through any collection:

// This is our Iterator interface - the key to the pattern
interface Iterator {
  // Are there more items to process?
  hasNext(): boolean;
  
  // Get the next item and move forward
  next(): string;
}

Now we create iterator classes for each collection type:

// 1. Array Iterator
class ArrayIterator implements Iterator {
  private currentIndex = 0;
  
  constructor(private array: string[]) {}
  
  hasNext(): boolean {
    // Check if we've reached the end of the array
    return this.currentIndex < this.array.length;
  }
  
  next(): string {
    // Get current item and move to the next position
    return this.array[this.currentIndex++];
  }
}

// 2. Linked List Iterator
class LinkedListIterator implements Iterator {
  private currentNode: ListNode | null;
  
  constructor(startingHead: ListNode | null) {
    this.currentNode = startingHead;
  }
  
  hasNext(): boolean {
    // Check if we have a current node
    return this.currentNode !== null;
  }
  
  next(): string {
    // Get current name
    const name = this.currentNode!.name;
    
    // Move to next node
    this.currentNode = this.currentNode!.next;
    
    return name;
  }
}

// 3. Tree Iterator (in-order traversal)
class TreeIterator implements Iterator {
  // Stack to keep track of nodes
  private stack: TreeNode[] = [];
  
  constructor(root: TreeNode | null) {
    // Initialize by pushing leftmost path
    this.pushLeftPath(root);
  }
  
  // Helper to push all left children onto stack
  private pushLeftPath(node: TreeNode | null) {
    while (node !== null) {
      this.stack.push(node);
      node = node.left;
    }
  }
  
  hasNext(): boolean {
    // If stack has items, we have more nodes
    return this.stack.length > 0;
  }
  
  next(): string {
    // Pop the next node
    const node = this.stack.pop()!;
    
    // If it has a right child, push its left path
    if (node.right !== null) {
      this.pushLeftPath(node.right);
    }
    
    return node.name;
  }
}

Now we need to update our collections to create these iterators:

// First create an interface for all collections
interface NameCollection {
  // Each collection must provide an iterator
  createIterator(): Iterator;
}

// 1. Make our array a proper collection
class NamesArray implements NameCollection {
  constructor(private names: string[]) {}
  
  createIterator(): Iterator {
    return new ArrayIterator(this.names);
  }
}

// 2. Update our linked list
class NameList implements NameCollection {
  head: ListNode | null = null;
  
  // Keep existing addName method...
  
  createIterator(): Iterator {
    return new LinkedListIterator(this.head);
  }
}

// 3. Update our tree
class NameTree implements NameCollection {
  root: TreeNode | null = null;
  
  // Keep existing addName method...
  
  createIterator(): Iterator {
    return new TreeIterator(this.root);
  }
}

Now the magic happens. We can treat all collections the same way!

// One function that works with ANY collection
function printNames(collection: NameCollection) {
  const iterator = collection.createIterator();
  
  while (iterator.hasNext()) {
    const name = iterator.next();
    console.log(name);
  }
}

// Create our collections
const arrayCollection = new NamesArray(["Emma", "James", "Sarah", "Michael"]);
const listCollection = new NameList();
listCollection.addName("Emma");
listCollection.addName("James");
listCollection.addName("Sarah");
listCollection.addName("Michael");

const treeCollection = new NameTree();
treeCollection.addName("Emma");
treeCollection.addName("James");
treeCollection.addName("Sarah");
treeCollection.addName("Michael");

// Use the SAME function for all three!
console.log("Names from array:");
printNames(arrayCollection);

console.log("Names from linked list:");
printNames(listCollection);

console.log("Names from tree:");
printNames(treeCollection);

Do you see the beauty? We now have one consistent way to iterate through any collection, no matter how it stores data internally.

You will learn more from the next example.

A Music Library Example

Let’s build a simple music player that works with different music collections:

// First, define what a music track looks like
interface Song {
  title: string;
  artist: string;
  duration: number; // in seconds
}

// Iterator for songs
interface SongIterator {
  hasNext(): boolean;
  next(): Song;
}

// Interface for all music collections
interface MusicCollection {
  createIterator(): SongIterator;
}

// 1. Album (stored as an array)
class Album implements MusicCollection {
  constructor(private songs: Song[]) {}
  
  createIterator(): SongIterator {
    return new AlbumIterator(this.songs);
  }
}

class AlbumIterator implements SongIterator {
  private currentIndex = 0;
  
  constructor(private songs: Song[]) {}
  
  hasNext(): boolean {
    return this.currentIndex < this.songs.length;
  }
  
  next(): Song {
    return this.songs[this.currentIndex++];
  }
}

// 2. Playlist (stored as a linked list)
class PlaylistNode {
  constructor(public song: Song, public next: PlaylistNode | null = null) {}
}

class Playlist implements MusicCollection {
  private head: PlaylistNode | null = null;
  
  addSong(song: Song) {
    const newNode = new PlaylistNode(song);
    
    if (!this.head) {
      this.head = newNode;
      return;
    }
    
    let current = this.head;
    while (current.next) {
      current = current.next;
    }
    current.next = newNode;
  }
  
  createIterator(): SongIterator {
    return new PlaylistIterator(this.head);
  }
}

class PlaylistIterator implements SongIterator {
  private currentNode: PlaylistNode | null;
  
  constructor(head: PlaylistNode | null) {
    this.currentNode = head;
  }
  
  hasNext(): boolean {
    return this.currentNode !== null;
  }
  
  next(): Song {
    const song = this.currentNode!.song;
    this.currentNode = this.currentNode!.next;
    return song;
  }
}

// Our music player
function playSongs(collection: MusicCollection) {
  console.log("Starting playback...");
  
  const iterator = collection.createIterator();
  
  while (iterator.hasNext()) {
    const song = iterator.next();
    console.log(`Now playing: "${song.title}" by ${song.artist} (${song.duration}s)`);
    
    // In a real player, we would wait for the song to finish
  }
  
  console.log("Playback finished");
}

// Create an album
const albumSongs: Song[] = [
  { title: "Song 1", artist: "Artist A", duration: 180 },
  { title: "Song 2", artist: "Artist A", duration: 210 },
  { title: "Song 3", artist: "Artist A", duration: 195 }
];
const myAlbum = new Album(albumSongs);

// Create a playlist
const myPlaylist = new Playlist();
myPlaylist.addSong({ title: "Favorite 1", artist: "Artist B", duration: 200 });
myPlaylist.addSong({ title: "Favorite 2", artist: "Artist C", duration: 185 });
myPlaylist.addSong({ title: "Favorite 3", artist: "Artist D", duration: 225 });

// Play the album
console.log("Playing album:");
playSongs(myAlbum);

// Play the playlist
console.log("\nPlaying playlist:");
playSongs(myPlaylist);

The playSongs function doesn’t need to know whether it’s playing an album (array) or a playlist (linked list). It just uses the iterator.

Special Iterators: Filtering

Iterators can do more than just iterate. They can filter, transform, or provide special access patterns:

// A filtering iterator that only returns songs longer than a certain duration
class LongSongsIterator implements SongIterator {
  private iterator: SongIterator;
  private minimumDuration: number;
  private nextSong: Song | null = null;
  
  constructor(iterator: SongIterator, minimumDuration: number) {
    this.iterator = iterator;
    this.minimumDuration = minimumDuration;
    this.findNextLongSong(); // Prime the pump - find first match
  }
  
  private findNextLongSong() {
    this.nextSong = null;
    
    while (this.iterator.hasNext()) {
      const song = this.iterator.next();
      if (song.duration >= this.minimumDuration) {
        this.nextSong = song;
        break;
      }
    }
  }
  
  hasNext(): boolean {
    return this.nextSong !== null;
  }
  
  next(): Song {
    const song = this.nextSong!;
    this.findNextLongSong(); // Find the next long song
    return song;
  }
}

// Usage: Find all songs longer than 3 minutes (180 seconds)
function playLongSongs(collection: MusicCollection) {
  console.log("Playing songs longer than 3 minutes:");
  
  const baseIterator = collection.createIterator();
  const longSongsIterator = new LongSongsIterator(baseIterator, 180);
  
  while (longSongsIterator.hasNext()) {
    const song = longSongsIterator.next();
    console.log(`Now playing: "${song.title}" by ${song.artist} (${song.duration}s)`);
  }
}

// Play only long songs from our album
playLongSongs(myAlbum);

In modern JavaScript and TypeScript, iterators are built into the language:

class Album implements Iterable<Song> {
  constructor(private songs: Song[]) {}
  
  // This special method makes the class work with for...of loops
  [Symbol.iterator](): Iterator<Song> {
    let index = 0;
    const songs = this.songs;
    
    return {
      next(): IteratorResult<Song> {
        if (index < songs.length) {
          return {
            value: songs[index++],
            done: false
          };
        }
        return { value: undefined as any, done: true };
      }
    };
  }
}

// Now we can use built-in language features
const album = new Album(albumSongs);

// For...of loop
for (const song of album) {
  console.log(`${song.title} - ${song.artist}`);
}

// Spread operator
const allSongs = [...album];

// Destructuring
const [firstSong, secondSong] = album;

This is very powerful because you get to use all the language’s built-in features for working with collections.

When to Use the Iterator Pattern

The Iterator Pattern is perfect when:

  1. You have different types of collections (arrays, lists, trees, etc.)
  2. You want one consistent way to access elements
  3. You want to hide the internal structure of collections
  4. You need multiple ways to traverse the same collection

Why the Iterator Pattern Matters

The Iterator Pattern gives us these benefits:

  1. Simplicity: One consistent way to access elements
  2. Flexibility: Collections can change internally without breaking client code
  3. Separation of concerns: Traversal logic is separated from collection logic
  4. Multiple traversals: You can have different types of iteration for the same collection

Final Thoughts

The Iterator Pattern provides a standard way to access collections without exposing their internal structure.

It’s like having a universal remote for all your collections. You handle them all the same way, regardless of what’s inside—arrays, lists, trees, graphs.

Next time you find yourself writing different loops for different collections, remember the Iterator Pattern. It might be the simplicity your code needs.

The collections remain, but now accessing them is as easy as hasNext() and next().

Related Articles

Back to top button