# Generics & iterators

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

Backend with Go roadmap · Foundations · 17 min read · https://levelcode.org/roadmaps/go-backend/go-generics

The [structs and interfaces lesson](https://levelcode.org/roadmaps/go-backend/go-types-and-interfaces) 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:

| Constraint | Allows | Use for |
|---|---|---|
| `any` | Every type | Containers, 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
	}
}
```

> [!WARNING]
> Once `yield` returns `false`, the iterator must not call it again. Doing so panics at runtime. Always `return` right after a `false`.

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.

**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.

## Quiz

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

   A. Cents is not comparable
   B. The set contains int64 itself, not types whose underlying type is int64; ~int64 would accept it
   C. Generic functions can't take slices
   D. Cents needs a String method

   **Answer: B.** Without the tilde, the type set is exactly int and int64. ~int64 includes every type whose underlying type is int64, such as Cents.

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

   A. The extra values are silently dropped
   B. The loop restarts
   C. It panics at runtime
   D. The compiler rejects the iterator

   **Answer: C.** Calling yield after it has returned false is a runtime error in range-over-func. Always return immediately when yield returns false.

3. Which is the best candidate for generics?

   A. A typed TTL cache used for profiles, sessions and feature flags
   B. The checkout service's business logic
   C. An HTTP handler for creating orders
   D. Different payment providers with different charge behaviour

   **Answer: A.** A cache is about data shape and is otherwise copied per type. Business logic and handlers should be concrete, and different behaviour behind one method is a job for interfaces.

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

   A. Sorts the map in place
   B. Returns an iterator over sorted keys
   C. Fails to compile because maps.Keys returns a slice
   D. Collects the map's keys from an iterator into a new, sorted slice

   **Answer: D.** maps.Keys returns an iter.Seq over the keys, and slices.Sorted collects any iter.Seq of ordered values into a sorted slice.
