Step 04 of 24

Generics & iterators

Write type-safe reusable Go with constraints and type sets, use the slices and maps packages, and build range-over-func iterators.

17 min 7 sections

The structs and interfaces lesson introduced type parameters with Filter, MaxOf and Stack. This lesson goes further into the parts you meet in real backend code: writing your own constraints, the ~ operator, the generic slices and maps packages, generic helpers for HTTP handlers and caches, and iterators, which Go 1.23 made a first-class language feature with range-over-func. It ends with the limits of Go's generics and when to leave them out.

Constraints are type sets

A constraint is an interface, but read as a set of types a type parameter may be. Since Go 1.18 an interface can list types with |:

go
type Number interface {
	int | int32 | int64 | float32 | float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

+ compiles because every type in the set supports it. Now define a domain type:

go
type Cents int64

var prices = []Cents{1999, 2499}
Sum(prices) // compile error: Cents does not satisfy Number

Cents is a new named type whose underlying type is int64, but the set lists only int64 itself. The tilde fixes it: ~int64 means "any type whose underlying type is int64".

go
type Number interface {
	~int | ~int32 | ~int64 | ~float32 | ~float64
}

Sum(prices) // Cents(4498)

Use ~ in almost every constraint you write, so your functions work with the named types that good Go code is full of: UserID, Cents, Status.

The standard library has the constraints you'll need most:

ConstraintAllowsUse for
anyEvery typeContainers, passing values through
comparable== and !=Map keys, deduplication, lookups
cmp.Ordered<, >, and == (integers, floats, strings, all with ~)Sorting, min, max, binary search

Constraints can also require methods, and mix both kinds:

go
// Any string-based type that can validate itself.
type Code interface {
	~string
	Valid() bool
}

The slices and maps packages

Before writing a generic helper, check whether slices or maps (both Go 1.21) already has it. They replace a lot of hand-written loops:

go
import (
	"cmp"
	"maps"
	"slices"
)

ids := []int{42, 7, 19, 7}
slices.Sort(ids)                       // [7 7 19 42]
ids = slices.Compact(ids)              // [7 19 42] (removes adjacent duplicates)
i, found := slices.BinarySearch(ids, 19) // 1 true
slices.Contains(ids, 42)               // true
slices.Index(ids, 7)                   // 0

type User struct {
	Name  string
	Score int
}
users := []User{{"ana", 90}, {"ben", 75}, {"cy", 90}}

// Sort by score descending, then name ascending.
slices.SortFunc(users, func(a, b User) int {
	return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Name, b.Name))
})

best := slices.MaxFunc(users, func(a, b User) int { return cmp.Compare(a.Score, b.Score) })

cmp.Or (Go 1.22) returns its first non-zero argument, which makes multi-key comparisons one line. slices.SortStableFunc keeps equal elements in their original order when that matters.

For maps, maps.Clone, maps.Copy and maps.DeleteFunc cover most needs, and maps.Keys/maps.Values return iterators, covered below.

Generic helpers worth writing

In a web service, a few generic functions remove real repetition. A JSON decoder that returns the decoded value:

go
func decodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) {
	var v T
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(&v); err != nil {
		return v, fmt.Errorf("decode %T: %w", v, err)
	}
	return v, nil
}

func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
	req, err := decodeJSON[CreateOrderRequest](w, r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	// req is a CreateOrderRequest, no type assertion needed
}

Here T can't be inferred from arguments, so you pass it explicitly: decodeJSON[CreateOrderRequest].

A paginated response type that every list endpoint can share:

go
type Page[T any] struct {
	Items      []T    `json:"items"`
	NextCursor string `json:"next_cursor,omitempty"`
}

And a small concurrency-safe cache with expiry:

go
type entry[V any] struct {
	value   V
	expires time.Time
}

type TTLCache[K comparable, V any] struct {
	mu   sync.Mutex
	ttl  time.Duration
	data map[K]entry[V]
}

func NewTTLCache[K comparable, V any](ttl time.Duration) *TTLCache[K, V] {
	return &TTLCache[K, V]{ttl: ttl, data: make(map[K]entry[V])}
}

func (c *TTLCache[K, V]) Get(key K) (V, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	e, ok := c.data[key]
	if !ok || time.Now().After(e.expires) {
		var zero V
		return zero, false
	}
	return e.value, true
}

func (c *TTLCache[K, V]) Set(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = entry[V]{value: value, expires: time.Now().Add(c.ttl)}
}

K must be comparable because it's a map key. Callers get full type safety: a *TTLCache[UserID, Profile] can't accidentally return an Order.

Iterators with range-over-func

Before Go 1.23, a custom collection couldn't be used with range. You exposed a slice (copying everything) or a callback method with its own conventions. Now range accepts functions of these shapes, defined in the iter package:

go
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

An iterator calls yield for each element. If yield returns false, the loop body hit break or return, and the iterator must stop.

go
type Set[T comparable] struct{ m map[T]struct{} }

func (s *Set[T]) Add(v T) {
	if s.m == nil {
		s.m = make(map[T]struct{})
	}
	s.m[v] = struct{}{}
}

// All returns an iterator over the set's elements, in no particular order.
func (s *Set[T]) All() iter.Seq[T] {
	return func(yield func(T) bool) {
		for v := range s.m {
			if !yield(v) {
				return
			}
		}
	}
}

var tags Set[string]
tags.Add("go")
tags.Add("backend")
for t := range tags.All() {
	fmt.Println(t)
}

Iterators compose with the standard library: slices.Collect(seq) builds a slice, slices.Sorted(seq) builds a sorted one, and maps.Keys(m) returns an iterator over a map's keys.

go
sortedTags := slices.Sorted(tags.All())          // []string{"backend", "go"}
keys := slices.Sorted(maps.Keys(map[string]int{"b": 2, "a": 1})) // [a b]
for i, v := range slices.All([]string{"x", "y"}) {                // Seq2[int, string]
	fmt.Println(i, v)
}

Iterators are lazy: nothing runs until something ranges over them, and work stops at the first break. That makes them a good fit for streaming results from a database without loading everything into memory:

go
// Rows yields orders one at a time; the caller can stop early.
func (s *Store) Orders(ctx context.Context, customerID int64) iter.Seq2[Order, error] {
	return func(yield func(Order, error) bool) {
		rows, err := s.db.Query(ctx, `SELECT id, total FROM orders WHERE customer_id = $1`, customerID)
		if err != nil {
			yield(Order{}, err)
			return
		}
		defer rows.Close()
		for rows.Next() {
			var o Order
			if err := rows.Scan(&o.ID, &o.Total); err != nil {
				yield(Order{}, err)
				return
			}
			if !yield(o, nil) {
				return // caller broke out; defer closes rows
			}
		}
		if err := rows.Err(); err != nil {
			yield(Order{}, err)
		}
	}
}

for o, err := range store.Orders(ctx, 42) {
	if err != nil {
		return err
	}
	if o.Total > 10_000 {
		break // rows are closed by the iterator's defer
	}
}

Occasionally you need to pull values one at a time instead of ranging, for example to merge two sorted sequences. iter.Pull(seq) converts a push iterator into next, stop functions; always defer stop() so its resources are released.

Limits, and when not to use generics

Go's generics are deliberately small. Know what they can't do:

  • Methods can't have their own type parameters. func (c *Cache[K, V]) Map[U any](...) doesn't compile. Write a top-level function instead.
  • You can't switch on T directly. Converting to any first works (switch any(v).(type)), but needing that is usually a sign you wanted an interface.
  • No specialisation. A generic function behaves the same for every type argument.
  • Performance isn't automatically better. Go compiles one copy of a generic function per memory shape (all pointer types share one) and passes a dictionary for the rest. Calling interface methods on a type parameter can be slower than a plain interface call. Benchmark before claiming a speed-up.

A useful rule: write the concrete version first. Make it generic when you're about to write the second or third copy for a different type, and the code is about data shape (containers, slices, pagination, caches), not behaviour (that's what interfaces are for). Handlers, services and domain logic should almost never be generic.

Practice

  1. Write GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T and use it to group orders by status.
  2. Write Filter[T any](seq iter.Seq[T], keep func(T) bool) iter.Seq[T] and Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] as lazy iterator adapters.
  3. Use them with slices.Values and slices.Collect to turn []Order into the IDs of orders over 100, without building any intermediate slice.
  4. Write a test that proves Filter stops calling the source as soon as the loop breaks.
Show solution
go
package orders

import (
	"iter"
	"slices"
)

type Order struct {
	ID     int64
	Status string
	Total  int
}

func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
	out := make(map[K][]T)
	for _, it := range items {
		k := key(it)
		out[k] = append(out[k], it)
	}
	return out
}

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

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

func BigOrderIDs(orders []Order) []int64 {
	big := Filter(slices.Values(orders), func(o Order) bool { return o.Total > 100 })
	return slices.Collect(Map(big, func(o Order) int64 { return o.ID }))
}
go
package orders

import "testing"

func TestFilterStopsEarly(t *testing.T) {
	pulled := 0
	source := func(yield func(int) bool) {
		for i := range 1000 {
			pulled++
			if !yield(i) {
				return
			}
		}
	}
	for v := range Filter(source, func(n int) bool { return n%2 == 0 }) {
		if v == 4 {
			break
		}
	}
	if pulled != 5 { // 0,1,2,3,4
		t.Fatalf("source produced %d values, want 5", pulled)
	}
}

func TestBigOrderIDs(t *testing.T) {
	got := BigOrderIDs([]Order{{1, "paid", 50}, {2, "paid", 150}, {3, "new", 300}})
	if len(got) != 2 || got[0] != 2 || got[1] != 3 {
		t.Fatalf("got %v", got)
	}
}

range 1000 (ranging over an integer) needs Go 1.22 or newer. Because both adapters return as soon as yield returns false, a break in the consumer propagates all the way back to the source.

Key takeaways

  • Constraints are type sets; write ~int64 rather than int64 so named types like Cents qualify.
  • Reach for slices, maps and cmp before writing your own helpers; cmp.Or makes multi-key sorting one line.
  • Generics pay off for data-shaped code: decode helpers, Page[T], typed caches. Domain logic should stay concrete.
  • iter.Seq and iter.Seq2 let any function be used with range; iterators are lazy and must stop when yield returns false.
  • Methods can't take type parameters, and generics aren't automatically faster; measure.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why does Sum[T int | int64](values []T) reject a []Cents, where type Cents int64?

  2. 2.Your iterator ignores yield's return value and keeps calling it after the loop body breaks. What happens?

  3. 3.Which is the best candidate for generics?

  4. 4.What does slices.Sorted(maps.Keys(m)) do?

Finished reading?

Mark it complete to fill in your roadmap.