How to use errors with iterator functions

The new iterator function feature in Go 1.23 lets you easily write a type that a range loop can iterate over.

However, what if the iteration may fail for some reason? Consider the following example. Assume you want to write an audio player library that lets clients provide a playlist and a list of indices to play from that playlist. Since you have no control over the contents of the list of indices, you need to check each index. If an index is outside the bounds of the playlist, the loop should stop with an error.

The iterator function that the range operator accepts do not return errors. So how can the loop error out?

Below is a standard implementation of the scenario, with no error handling. Note that the iterator function All() checks the index but cannot return an error.

type Song struct {
	Title  string
	Artist string
}

type Playlist struct {
	list    []Song
	indices []int
}

func (p *Playlist) All(yield func(int, Song) bool) {
	for _, index := range p.indices {
		if index < 0 || index >= len(p.list) {
			break  // we can only break the loop, but not return any error!
		}
		song := p.list[index]
		if !yield(index, song) {
			break
		}
	}
}

func (ps *Playlist) Err() error {
	return ps.err
}

func main() {
	playlist := []Song{
		{"As It Was", "Harry Styles"},
		{"Anti-Hero", "Taylor Swift"},
		{"Shape of You", "Ed Sheeran"},
		{"Blinding Lights", "The Weeknd"},
		{"Bad Guy", "Billie Eilish"},
	}

	indices := []int{0, 2, 4, 6, 1}

	play := &Playlist{list: playlist, indices: indices}

	for index, song := range play.All {
		fmt.Printf("Now playing: %d – %s by %s\n", index, song.Title, song.Artist)
	}
}

If you run this code, the range loop ends after Shape of You, because index 6 is outside the bounds of the playlist. Yet, you cannot tell from the result if the player finished the playlist or if an error occurred.

In a golang-nuts thread, someone suggested to implement error handling in the style of bufio.Scanner, that uses a separate Err() method to check for success or failure:

	for scanner.Scan() {
		fmt.Printf("%s\n", scanner.Text())
	}

	if err := scanner.Err(); err != nil {
		fmt.Printf("Invalid input: %s", err)
	}

Can we add this to the playlist iterator? Nothing easier than that!

First, add an err field to the Playlist struct:

type Playlist struct {
	list    []Song
	indices []int
	err     error
}

Next, when checking the index, set the error before breaking the loop:

		if index < 0 || index >= len(p.list) {
			p.err = fmt.Errorf("invalid index: %d", index)
			break
		}

Finally, the loop user can check the error after the loop finishes:

	for index, song := range play.All {
		fmt.Printf("Now playing: %d – %s by %s\n", index, song.Title, song.Artist)
	}

	if err := play.Err(); err != nil {
		fmt.Printf("Playlist error: %v\n", err)
	}

Find the full code in the Go Playground.

Of course, this is not the only option.

For example, if you don't need the index, you can use one of the yield function's parameters to hold an error.

In a nutshell:

Change All() to yield the current song and an error:

func (p *Playlist) All(yield func(Song, error) bool) {
	for _, index := range p.indices {
		if index < 0 || index >= len(p.list) {
			yield(Song{}, fmt.Errorf("Index out of range: %d", index))
			break
		}
		song := p.list[index]
		if !yield(song, nil) {
			break
		}
	}
}

Then change the range loop to this:

	for song, err := range play.All {
		if err != nil {
			fmt.Printf("Cannot continue: %v\n", err)
			break
		}
		fmt.Printf("Now playing: %s by %s\n", song.Title, song.Artist)
	}

(Full code in the Playground.)

Of course, this only works if the iterator returns only one value.

If you want to yield two values but don't like the bufio.Scanner-style solution, you can bake the error into the return value:

type Song struct {
	Title  string
	Artist string
  Err error
}

// ...

for index, song := range play.All {
  if song.Err != nil { ... }
  // ...
}

( Playground)

These are just three options of smuggling error handling into the range-over-func mechanism. You will certainly find more variants if you drop the requirement of being compatible with the range operator.