Last week I introduced the topic of range-over-func as an iterator pattern. Let’s look at how error handling differs with this approach.
For context, let’s compare the custom iterator approach, which has three distinct errors to check: 1. The initial function call, 2. while iterating over each row, 3. finally at the end, to ensure iteration completed successfully.
orders, err := db.Orders(ctx, userID)
if err != nil {
return err
}
defer orders.Close() // Ensure that the iterator is cleaned up, even in case of early return
for orders.Next() {
order, err := orders.Process()
if err != nil {
return err
}
/* Do something with each order */
}
if err := orders.Err(); err != nil {
return err
}
With the range-over-func variant, with only two errors to check: 1. The initial function call, and 2. while iterating over each row.
orders, err := db.Orders(ctx, userID)
if err != nil {
return err
}
for order, err := range orders {
if err != nil {
return err
}
/* Do something with each order */
}
This reduction in errors is possible because range-over-func makes it easy to return an iteration error in the loop. The same can actually be accomplished with a custom iterator, as well, by changing the signature of Next from Next() bool to Next() error. This would make it possible to detect iteration errors within the iteration loop. This approach has some trade-offs. Perhaps most noticably, it makes the code slightly more cumbersome:
for {
err := orders.Next()
if err != nil {
/* .. handle error .. */
}
/* Do something with each order */
}
It does have the advantage of making it much less likely to forget to check the iterator error, though.
Whether using range-over-func or a custom iterator, consolidating the error handling has one potential, though in my experience rare, drawback, that being that it’s not obvious when the error occurred. If your application needs to know that an error occurred when processing a row, versus during iteration, that’s less obvious. Custom error types can address this, but it is a bit more work.
And with that in mind, let’s discuss going one step further, and reducing error checking to a single instance,with range-over-func:
for order, err := range db.Orders(ctx, userID) {
if err != nil {
return err
}
/* Do something with each order */
}
In this variant, there’s only a single place to check an error. If the function call itself fails, it would return a single value-error pair with a non-nil error.
This is, generally, my preferred way to build iterators now. There’s just one caveat: Some iterators can return multiple errors. Others can return only a single error, which aborts the operation. As a consumer of a range-over-func iterator, you need to be aware of which you’re dealing with.
Here’s an example: Suppose you’re querying the Spotify API (something I do frequently, actually) for a list of 10 artists. But Spotify doesn’t know about two of those 10 artists, because one is your cousin’s brand new band, and they haven’t been published on Spotify yet, and the other includes a typo. In this case, the iterator may return results 1-3, then a not-found error, then results 5-9, then another not-found error.
If you abort as soon as an error occurs, you’ll miss some of the legitimate results.
var errs []error
for artist, err := range spotify.Artists(ctx, userID) {
if err != nil {
errs = append(errs, err)
}
/* Do something with each artist */
}
if len(errs) > 0 {
log.Error("failed to fetch some Spotify artists", slog.Any("error", errors.Join(errs...)))
}
If you’re unsure whether a range-over-func iterator may return multiple errors, you can just program defensively, and assume it always does. Even if it only returns a single error, then aborts, your code should still work properly.