Range-over-Func: Custom Iterators in Go

December 3, 2024

Range-over-Func: Custom Iterators in Go
Range-over-Func: Custom Iterators in Go

Go 1.23 made functions valid range targets. You can now iterate over anything — not just slices, maps, and channels.

Estimated Reading Time : 9m

What changed

Before Go 1.23, for range worked on a fixed set of types: arrays, slices, maps, strings, channels, and integers. If you had a custom data structure, you returned a slice and the caller ranged over that.

Go 1.23 adds iterator functions — functions that yield values one at a time to a for range loop. No intermediate slice, no channel goroutine, no special interface.

The iterator signatures

The iter package defines two function types:

// yields a single value per iteration
type Seq[V any] func(yield func(V) bool)

// yields a key-value pair per iteration
type Seq2[K, V any] func(yield func(K, V) bool)

The iterator calls yield for each value. If yield returns false, the loop has stopped (via break or return) and the iterator should exit.

A simple example

An iterator that yields integers from 0 to n-1:

func UpTo(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := range n {
            if !yield(i) {
                return
            }
        }
    }
}

// usage
for v := range UpTo(5) {
    fmt.Println(v)
}
// 0 1 2 3 4

The for range loop calls your function, and your function calls yield for each value. When the loop body runs, it’s inside the yield call. When the loop breaks, yield returns false.

Iterating over a custom data structure

A linked list that’s iterable without converting to a slice first:

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

func (n *Node[T]) All() iter.Seq[T] {
    return func(yield func(T) bool) {
        for current := n; current != nil; current = current.Next {
            if !yield(current.Value) {
                return
            }
        }
    }
}

// usage
head := &Node{Value: "a", Next: &Node{Value: "b", Next: &Node{Value: "c"}}}
for v := range head.All() {
    fmt.Println(v)
}

No allocation, no intermediate slice. The loop walks the list node by node.

Key-value iterators with Seq2

Seq2 yields two values — useful for index-value or key-value pairs:

func Enumerate[T any](s []T) iter.Seq2[int, T] {
    return func(yield func(int, T) bool) {
        for i, v := range s {
            if !yield(i, v) {
                return
            }
        }
    }
}

for i, name := range Enumerate([]string{"alice", "bob", "carol"}) {
    fmt.Printf("%d: %s\n", i, name)
}

Composable iterators

Iterators compose naturally. A Filter that wraps any iterator:

func Filter[V any](seq iter.Seq[V], predicate func(V) bool) iter.Seq[V] {
    return func(yield func(V) bool) {
        for v := range seq {
            if predicate(v) {
                if !yield(v) {
                    return
                }
            }
        }
    }
}

// usage: even numbers from 0 to 9
for v := range Filter(UpTo(10), func(n int) bool { return n%2 == 0 }) {
    fmt.Println(v)
}
// 0 2 4 6 8

A Map that transforms values:

func Map[V, R any](seq iter.Seq[V], transform func(V) R) iter.Seq[R] {
    return func(yield func(R) bool) {
        for v := range seq {
            if !yield(transform(v)) {
                return
            }
        }
    }
}

// usage
for s := range Map(UpTo(5), strconv.Itoa) {
    fmt.Println(s) // "0" "1" "2" "3" "4"
}

Chain them together:

for v := range Map(Filter(UpTo(20), isEven), double) {
    fmt.Println(v)
}

Standard library support

Go 1.23 added iterator support to existing packages:

slices package:

slices.All(s)        // iter.Seq2[int, E] — index and value
slices.Values(s)     // iter.Seq[E] — values only
slices.Backward(s)   // iter.Seq2[int, E] — reverse order
slices.Collect(seq)  // []E — collect an iterator into a slice

maps package:

maps.All(m)    // iter.Seq2[K, V] — all key-value pairs
maps.Keys(m)   // iter.Seq[K] — keys only
maps.Values(m) // iter.Seq[V] — values only

slices.Collect is particularly useful — it materializes any iterator into a slice:

evens := slices.Collect(Filter(UpTo(20), func(n int) bool { return n%2 == 0 }))
// [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

The yield contract

The rules for writing iterators:

  1. Always check yield’s return value. If yield returns false, stop immediately. The loop is done.
  2. Don’t call yield after it returns false. The behavior is undefined.
  3. Cleanup still works. defer statements in the iterator run when the iterator returns, which happens when the loop ends (naturally or via break).
  4. Iterators are single-use by convention. Calling the function returns a fresh iterator each time, but don’t reuse the same function value across multiple range loops.

When to use iterators

Lazy evaluation. When you don’t want to materialize the entire result set — reading lines from a file, paginating API results, walking a tree.

Custom data structures. Linked lists, trees, graphs — anything where the standard slice/map iteration doesn’t apply.

Composable pipelines. Filter → Map → Take → Collect chains without intermediate allocations.

When not to use them. If you already have a slice and you’re just ranging over it, for range on the slice is simpler and clearer. Don’t wrap a slice in an iterator for the sake of it.