Iterator Design Pattern in Swift


Greetings, traveler!

We use iterators every day without thinking about them. Every for-in loop, every call to map or filter relies on the same mechanism. Swift hides most of the plumbing behind Sequence and IteratorProtocol, so the pattern feels almost invisible.

Still, it is worth understanding what actually happens under the hood. Once you see it clearly, custom traversal logic stops looking mysterious.

What the iterator pattern solves

The iterator pattern separates two concerns:

  • how a collection stores its elements
  • how those elements are accessed one by one

The client code should not care whether data lives in an array, a tree, a file stream, or a network buffer. It only needs a way to request the “next” element until there are no more.

In classic GoF terms:

  • Aggregate → the container
  • Iterator → the object that moves through elements
  • Client → the code that consumes elements

In Swift:

  • Sequence plays the role of Aggregate
  • IteratorProtocol represents Iterator
  • for-in acts as the Client

Once you see that mapping, the pattern stops feeling abstract.

How Swift’s for-in loop actually works

A for-in loop:

for element in collection {
		print(element)
}

is roughly equivalent to:

var iterator = collection.makeIterator()

while let element = iterator.next() {
    print(element)
}

That is the whole story. The loop repeatedly calls next() until it returns nil.

So if you want custom iteration behavior, you control makeIterator() and next().

Implementing a custom iterator

Let’s build something slightly more interesting than a thin wrapper around an array. Imagine we have audio cassettes, but we only want to iterate over those released in the 90s.

First, define the model:

struct AudioCassette {
    let title: String
    let year: Int
}

Now create a container:

struct AudioCassettes {
    private let storage: [AudioCassette]

    init(_ storage: [AudioCassette]) {
        self.storage = storage
    }
}

Next, define the iterator:

struct NinetiesIterator: IteratorProtocol {

    private let storage: [AudioCassette]
    private var currentIndex: Int = 0

    init(storage: [AudioCassette]) {
        self.storage = storage
    }

    mutating func next() -> AudioCassette? {
        while currentIndex < storage.count {
            defer { currentIndex += 1 }

            let cassette = storage[currentIndex]
            if cassette.year >= 1990 && cassette.year < 2000 {
                return cassette
            }
        }

        return nil
    }
}

A few important details:

  • The iterator is a struct.
  • next() is marked mutating because it changes internal state.
  • The iterator decides which elements are visible.

Now connect the container to the iterator:

extension AudioCassettes: Sequence {

    func makeIterator() -> NinetiesIterator {
        NinetiesIterator(storage: storage)
    }
}

And use it:

let collection = AudioCassettes([
    .init(title: "80s best disco music", year: 1985),
    .init(title: "90s best electronic music", year: 1994),
    .init(title: "90s rock hits", year: 1998)
])

for cassette in collection {
    print(cassette.title)
}

Only the 90s albums are printed. The client code never sees the filtering logic.

Why the iterator is a struct

It is tempting to ask: why not make it a class?

In Swift, iterators are usually value types. Each for-in loop receives its own copy of the iterator. That means independent state and predictable behavior.

Because next() moves the cursor forward, it must be mutating. That single keyword makes it clear that the iterator carries state internally.

This design aligns well with Swift’s value semantics. Iteration becomes explicit and controlled.

When you actually need a custom iterator

If you already have an array, you rarely need to write your own iterator. Standard library tools are powerful enough for most tasks.

Custom iterators become useful when:

  • elements are generated lazily
  • data is expensive to compute
  • traversal order is not trivial
  • you want to expose only a filtered or transformed view

A common example is reading a file line by line without loading everything into memory. Another is traversing a tree in depth-first or breadth-first order.

In those cases, the iterator controls the navigation strategy.

Iteration strategy as a design choice

One subtle benefit of this pattern is flexibility. The container decides which iterator to provide.

You could expose multiple traversal strategies:

extension AudioCassettes {

    func ninetiesOnly() -> some Sequence {
        storage.filter { $0.year >= 1990 && $0.year < 2000 }
    }
}

Or you could define separate iterators for different rules.

The key idea is that traversal logic does not leak into client code. The collection remains in control.

Conclusion

The iterator pattern in Swift looks simple because the language already did most of the heavy lifting. Still, understanding how Sequence and IteratorProtocol cooperate gives you more control over data access.

Behind every for-in loop there is a small state machine advancing one element at a time. Once you see that, custom traversal logic becomes a deliberate design decision rather than a workaround.

And sometimes that small layer of control is exactly what you need.

Well, that’s all for now. I hope this was clear and at least somewhat interesting for you. In the meantime, let’s move on to the next article, which will discuss the Mediator Design Pattern.