Mert Özen Further With Every Line
Backend Backend with Go — #06

Go Generics and Iterators: Type Parameters, Constraints, range-over-func and Generic Methods

Mert Özen Nov 15, 2026 17 min 4 views
Go Generics and Iterators: Type Parameters, Constraints, range-over-func and Generic Methods

In Go a constraint is a type set: unless you write ~int64, a named type such as Money cannot be passed to a generic function that asks for int64. Pagination in Bookstore with a generic Page[T], cmp.Ordered and comparable, how Go differs from C# generics (no variance, GC shape stenciling), an iter.Seq iterator that groups books by author, the yield trap, a comparison with the LINQ mindset, and Go 1.27's generic methods.

Version note: The code in this post was built and run with Go 1.27.4; the module's go line is 1.26.0 and the application code also compiles with Go 1.26.11. Iterators (range-over-func) arrived in Go 1.23 and self-referential constraints in Go 1.26. Generic methods are specific to Go 1.27; the lab file that shows them is set apart with a //go:build go1.27 line.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, we have kept prices in kuruş since post 03, using a Money type based on int64. Let's write a single generic Sum function that can add up both stock counts and prices. I gave it the constraint "int or int64":

package main

import "fmt"

type Money int64 // in kuruş

func Sum[N int | int64](values []N) N {
    var total N
    for _, v := range values {
        total += v
    }
    return total
}

func main() {
    stock := []int{12, 0, 4}
    prices := []Money{34990, 19900}
    fmt.Println(Sum(stock))
    fmt.Println(Sum(prices))
}

Underneath Money there is an int64. Does the program compile, and if it does, what do the two lines print?

go run .
# guess
./main.go:19:17: Money does not satisfy int | int64 (possibly missing ~ for int64 in int | int64)

It did not compile. The constraint int | int64 accepts exactly these two types; Money is a named type whose underlying type is int64, but it is a different type. The compiler even names the fix: writing ~int | ~int64 means "any type whose underlying type is int or int64", and the program prints 16 and 54890. This small mark shows where Go generics think differently from C#: constraints describe a set of types. In this post we look at type parameters, constraints, iterators and the type parameters that methods gained in Go 1.27, using Bookstore's catalog and pagination code.

Type Parameters and Constraints

Generics came to Go with 1.18, in March 2022. A function takes type parameters in square brackets, and every type parameter has a constraint. A constraint is always an interface, but besides a method list that interface can also describe a set of types. The lab has three different constraints:

// Lab 06: type parameters, constraints and the generic paging.Page.
package main

import (
    "cmp"
    "fmt"

    "bookstore/internal/catalog"
    "bookstore/internal/paging"
)

// Integer is any type whose underlying type is int or int64,
// so named types such as catalog.Money qualify too.
type Integer interface {
    ~int | ~int64
}

// Sum adds up the values.
func Sum[N Integer](values []N) N {
    var total N
    for _, v := range values {
        total += v
    }
    return total
}

// MaxBy returns the item with the largest key; ok is false for an empty slice.
func MaxBy[T any, K cmp.Ordered](items []T, key func(T) K) (best T, ok bool) {
    for i, it := range items {
        if i == 0 || key(it) > key(best) {
            best, ok = it, true
        }
    }
    return best, ok
}

// IndexBy builds a lookup map; K must be comparable to be a map key.
func IndexBy[K comparable, V any](items []V, key func(V) K) map[K]V {
    m := make(map[K]V, len(items))
    for _, it := range items {
        m[key(it)] = it
    }
    return m
}

Integer is a union: types separated by |, with ~ meaning "every type with this underlying type". Such an interface can only be used as a constraint, never as the type of a variable. cmp.Ordered is the standard library's ready-made constraint: every integer, floating-point and string type that supports the < and > operators. comparable is the language's own constraint for types that can be compared with ==; a map key needs it. The loosest constraint is any: it asks for nothing, but you also cannot do anything with a value of that type.

go run ./labs/06-generics
sum of prices: 1543.40 TRY
books in stock: 31
most expensive: Dune | longest title: Sense and Sensibility
9780141439686 -> Persuasion
page 2/2, 2 of 6 items, len=2 cap=2
   Persuasion
   Sense and Sensibility
page 9: 0 items, nil=false
paging.Page[bookstore/internal/catalog.Book]
paging.Page[string]

I did not write a type argument in any of the calls; the compiler inferred them from the arguments. In Sum(prices) N became catalog.Money, and because the result is a Money too, Println used its String() method. In the MaxBy calls it found K from the key function's return type. In this post I also added two more Jane Austen books to the catalog, Persuasion and Sense and Sensibility; I wanted an author with more than one book for the grouping example.

How It Differs from C# Generics

If you have read "C# Generic Class, Generic Method ve Constraint'ler" in the C# Koleksiyonlar ve Generics series, you will see a few important differences beyond syntax. In C# a constraint is a list such as where T : IComparable<T>, new() and says "this type implements that interface". In Go a constraint is a type set; an expression like ~int64 has no direct C# counterpart. Generic math in .NET 7, with INumber<T>, answers the same need with an interface hierarchy.

There is no variance. In the same series' "C# Generic Variance" post we saw that an IEnumerable<string> can be assigned to an IEnumerable<object>. In Go a []string cannot be passed to a function that expects []any; the compiler says cannot use titles (variable of type []string) as []any value in argument to show. The two have different memory layouts, so converting takes a loop.

The runtime model is different too. In .NET generic types exist at runtime as well: separate native code is generated for List<int>, reference types share code, and reflection shows you the type argument. The Go compiler uses what its design document calls "GC shape stenciling": types with the same memory shape, for example all pointers, share one copy of the code and receive type-specific information through a hidden dictionary parameter. That is why generic code is not always faster than hand-written code; measure if performance matters.

Finally, Go has no specialization, no keyword in place of default(T) and no new() constraint. You get a type's zero value with var zero T; the var total N in Sum is exactly that.

A Generic Page[T]: Pagination in Bookstore

As the catalog grows, GET /api/books cannot return the whole list in one go. I put the Go counterpart of the PagedResult<T> from the ASP.NET Core Web API series' "Pagination, Filtering ve Sorting" post into the internal/paging package:

// Package paging splits lists into pages for API responses.
package paging

// Page is one page of a longer list.
type Page[T any] struct {
    Items      []T `json:"items"`
    Page       int `json:"page"`
    PageSize   int `json:"pageSize"`
    TotalItems int `json:"totalItems"`
    TotalPages int `json:"totalPages"`
}

// Of returns page number page (1-based) of items, size items per page.
// The caller validates page >= 1 and size >= 1.
func Of[T any](items []T, page, size int) Page[T] {
    total := len(items)
    start := min((page-1)*size, total)
    end := min(start+size, total)
    return Page[T]{
        Items:      items[start:end:end], // cap = len: an append cannot overwrite the next page
        Page:       page,
        PageSize:   size,
        TotalItems: total,
        TotalPages: (total + size - 1) / size,
    }
}

It is easy to see why generics really help here: the pagination logic is the same for books, orders or authors. The third number in items[start:end:end] comes from the slice header topic in post 03: by setting the capacity equal to the length, an append to this page opens a new array instead of overwriting the next element of the underlying catalog. The len=2 cap=2 in the lab output showed this. If a page beyond the last one is requested, start and end are both set to the list's length and an empty but non-nil slice comes back.

The handler validates the query parameters and passes the result to paging.Of:

mux.HandleFunc("GET /api/books", func(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query()
    list := store.All()
    if s := q.Get("maxPrice"); s != "" {
        limit, err := strconv.ParseInt(s, 10, 64)
        if err != nil {
            http.Error(w, "maxPrice must be a whole number of kurus", http.StatusBadRequest)
            return
        }
        list = store.CheaperThan(catalog.Money(limit))
    }
    page, okPage := positiveInt(q.Get("page"), 1)
    size, okSize := positiveInt(q.Get("pageSize"), 20)
    if !okPage || !okSize || size > 100 {
        http.Error(w, "page and pageSize must be positive whole numbers, pageSize at most 100", http.StatusBadRequest)
        return
    }
    writeJSON(w, paging.Of(list, page, size))
})
// positiveInt parses s as a whole number >= 1. An empty s gives def.
func positiveInt(s string, def int) (int, bool) {
    if s == "" {
        return def, true
    }
    n, err := strconv.Atoi(s)
    if err != nil || n < 1 {
        return 0, false
    }
    return n, true
}
curl -s 'http://127.0.0.1:52710/api/books?page=2&pageSize=2'
curl -s 'http://127.0.0.1:52710/api/books?page=7&pageSize=2'
curl -i -s 'http://127.0.0.1:52710/api/books?pageSize=500'
{"items":[{"isbn":"9780141439549","title":"Middlemarch","author":"George Eliot","price":31000,"stock":4},{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":19900,"stock":7}],"page":2,"pageSize":2,"totalItems":6,"totalPages":3}
{"items":[],"page":7,"pageSize":2,"totalItems":6,"totalPages":3}
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sun, 15 Nov 2026 01:28:50 GMT
Content-Length: 71

page and pageSize must be positive whole numbers, pageSize at most 100

With this change the response of /api/books turned from an array into an object. In a published API this would be a breaking change; since nobody uses Bookstore's Go API yet, doing it now is cheap. The error response is still plain text; in post 10 we switch to Problem Details.

When Not to Write Generic Code

In my own words, the advice in Ian Lance Taylor's "When To Use Generics" post from April 2022 is this: if the code stays the same line by line for several types, such as functions working on slices and maps or general-purpose data structures, write it generically. If all you do is call a method of the type argument, an interface is enough; turning a function that takes an io.Reader into [T io.Reader] gains nothing. If the implementation differs per type, you need separate functions or interface methods, not generics.

That is why I wrote the code that groups books by author as a method specific to catalog rather than a generic GroupBy. It has a single caller; a generic helper would ask the reader to keep three type parameters in mind. The generic version of the "put everything in a utils package" trap from post 02 is the same: instead of opening an internal/genericutil package, wait until the need shows up a second time.

Iterators: iter.Seq and range-over-func

Since Go 1.23 for range can loop over functions too. The iter package defines two types: iter.Seq[V] is really func(yield func(V) bool), and iter.Seq2[K, V] is its two-value form. An iterator is a function that calls yield for each value. I added two iterators to internal/catalog, the counterpart of the Bookstore.Catalog module on the C# side:

// Books yields every book in catalog order. Each value is a copy.
func (Store) Books() iter.Seq[Book] {
    return slices.Values(books)
}
// ByAuthor yields each author once, in the order they first appear in the
// catalog, together with that author's books.
func (Store) ByAuthor() iter.Seq2[string, []Book] {
    return func(yield func(string, []Book) bool) {
        var authors []string
        groups := make(map[string][]Book)
        for _, b := range books {
            if _, seen := groups[b.Author]; !seen {
                authors = append(authors, b.Author)
            }
            groups[b.Author] = append(groups[b.Author], b)
        }
        for _, a := range authors {
            if !yield(a, groups[a]) {
                return
            }
        }
    }
}

Books uses the standard library's slices.Values and walks the books one by one without handing out the slice itself; in post 03 we discussed why All() returns a copy, and with an iterator no copy is made at all. ByAuthor is a Seq2 we wrote ourselves. Since map iteration order is random, it keeps the author order in a separate slice, so the API returns the same order on every request.

The important line is if !yield(a, groups[a]) { return }. The compiler turns the body of the for range loop into a function and passes it to the iterator as yield. If the loop runs into break or return, yield returns false and the iterator has to stop. The yield return we saw in C# in the LINQ series' "LINQ'te Kendi Operatörünü Yazmak: Extension Method ve yield return" post generated a state machine; Go's approach is simpler, just a callback.

// Lab 06: iter.Seq, range-over-func, slices/maps helpers and a broken iterator.
package main

import (
    "fmt"
    "iter"
    "maps"
    "slices"

    "bookstore/internal/catalog"
)

// Filter yields the values of seq for which keep returns true.
func Filter[V any](seq iter.Seq[V], keep func(V) bool) iter.Seq[V] {
    return func(yield func(V) bool) {
        for v := range seq {
            if keep(v) && !yield(v) {
                return
            }
        }
    }
}

// countdown ignores yield's result: a bug the runtime reports.
func countdown(yield func(int) bool) {
    for i := 3; i > 0; i-- {
        yield(i)
    }
}

func main() {
    var store catalog.Store

    // 1) The loop body is the yield function; break makes yield return false.
    for b := range store.Books() {
        if b.Stock == 0 {
            fmt.Println("first sold-out book:", b.Title)
            break
        }
    }

    // 2) A Seq2 yields two values: author and books.
    for author, books := range store.ByAuthor() {
        fmt.Printf("%-15s %d\n", author, len(books))
    }

    // 3) Composing: filter lazily, collect into a slice at the end.
    inStock := Filter(store.Books(), func(b catalog.Book) bool { return b.Stock > 0 })
    cheap := Filter(inStock, func(b catalog.Book) bool { return b.Price < 25000 })
    var titles []string
    for b := range cheap {
        titles = append(titles, b.Title)
    }
    fmt.Println(titles)

    // 4) maps.Keys returns an iterator; map order is random, so sort it.
    stock := map[string]int{}
    for b := range store.Books() {
        stock[b.Title] = b.Stock
    }
    fmt.Println(slices.Collect(maps.Keys(stock))[:3])
    fmt.Println(slices.Sorted(maps.Keys(stock))[:3])

    // 5) An iterator that keeps calling yield after break.
    defer func() { fmt.Println("recovered:", recover()) }()
    for i := range countdown {
        fmt.Println("got", i)
        break
    }
}
go run ./labs/06-iterators
first sold-out book: The Hobbit
Frank Herbert   1
J.R.R. Tolkien  1
George Eliot    1
Jane Austen     3
[Emma Persuasion Sense and Sensibility]
[Persuasion Sense and Sensibility Dune]
[Dune Emma Middlemarch]
got 3
recovered: runtime error: range function continued iteration after function for loop body returned false

The last line shows a bug: countdown ignored yield returning false and called it again, and the runtime turned that into a panic. When you write your own iterator, check the result of yield every time.

slices, maps and the LINQ Mindset

The standard library gives you both ends of an iterator. The producing side: slices.All, slices.Values, slices.Backward, slices.Chunk, maps.Keys, maps.Values, maps.All. The consuming side: slices.Collect, slices.Sorted, slices.AppendSeq, maps.Collect. In the lab's fourth step the order of maps.Keys was random, while slices.Sorted gave the same order every time. When I ran the lab five times this line came in three different orders, such as [Dune The Hobbit Middlemarch] and [Middlemarch Emma Persuasion]; a test that depends on map order therefore sometimes passes and sometimes fails.

The middle is empty: adapters such as Where, Select and GroupBy are not in the standard library. I wrote the lab's Filter myself and nested two filters. This chain is lazy, as we described in the LINQ series' "LINQ Deferred Execution" post; values are only produced when for range asks for them. But Filter(Filter(seq, a), b) is hard to read. In Go the same job is usually written as a single loop with a few ifs, and the Go community sees that as a choice for readability, not as something missing. Until 1.27 there was also a technical obstacle to a chaining syntax: methods could not take their own type parameters, so you could not write books.Map(...).

Go 1.27: Generic Methods

According to the Go 1.27 release notes, a method can now declare its own type parameters. Since Bookstore's module is at go 1.26.0, I tried the feature in a lab with a file-level build constraint. The //go:build go1.27 line raises that file's language version to 1.27; when building with Go 1.26 the file is not compiled at all, and the //go:build !go1.27 file next to it says "this lab needs Go 1.27".

//go:build go1.27

// Lab 06: Go 1.27 generic methods. The build constraint above raises this
// file's language version to 1.27; the module itself stays at go 1.26.0.
package main

import (
    "fmt"
    "math/rand/v2"

    "bookstore/internal/catalog"
)

// List is a slice with chainable helpers.
type List[T any] []T

// Map converts every element; U is the method's own type parameter.
func (l List[T]) Map[U any](f func(T) U) List[U] {
    out := make(List[U], 0, len(l))
    for _, v := range l {
        out = append(out, f(v))
    }
    return out
}

// Where keeps the elements for which keep returns true.
func (l List[T]) Where(keep func(T) bool) List[T] {
    var out List[T]
    for _, v := range l {
        if keep(v) {
            out = append(out, v)
        }
    }
    return out
}

func main() {
    books := List[catalog.Book](catalog.Store{}.All())
    titles := books.
        Where(func(b catalog.Book) bool { return b.Author == "Jane Austen" }).
        Map(func(b catalog.Book) string { return b.Title })
    fmt.Println(titles, len(titles))

    prices := books.Map(func(b catalog.Book) catalog.Money { return b.Price })
    fmt.Println(prices[:2])

    r := rand.New(rand.NewPCG(1, 2))
    fmt.Println(r.N(10), r.N(catalog.Money(100))) // math/rand/v2: (*Rand).N is generic in 1.27
}
go run ./labs/06-genericmethod
[Emma Persuasion Sense and Sensibility] 3
[349.90 TRY 289.50 TRY]
7 0.61 TRY

Map[U any] adds its own U next to the type parameter of List[T], and a Where(...).Map(...) chain can now be written. The standard library gives the first example as well: N, a top-level function in math/rand/v2, became a generic method of *Rand in 1.27; above I called that method with both int and catalog.Money. If you delete the build constraint and build with the module's go 1.26.0 line, the compiler says generic method requires go1.27 or later (-lang was set to go1.26; check go.mod).

The limit is this: interface methods cannot take type parameters, and a generic method cannot satisfy an interface method. When I wrote Find[T any](key string) (T, bool) into an interface, the compiler said interface method must have no type parameters. The reason is Go's implicit interfaces: there is no way to generate code for a method when the type arguments it will be called with cannot be known at compile time.

The second change in 1.27 is function type inference: a generic function can be used without type arguments anywhere it is assigned to, or converted to, a matching function type. The self-referential constraints that came with Go 1.26 belong to the same topic; a generic type can now use itself in its own type parameter list:

package main

import (
    "cmp"
    "fmt"
)

// Comparer refers to itself in its own type parameter list (Go 1.26).
type Comparer[T Comparer[T]] interface {
    Compare(other T) int
}

type Version struct{ Major, Minor int }

func (v Version) Compare(o Version) int {
    return cmp.Or(cmp.Compare(v.Major, o.Major), cmp.Compare(v.Minor, o.Minor))
}

func Latest[T Comparer[T]](a, b T) T {
    if a.Compare(b) >= 0 {
        return a
    }
    return b
}

func main() {
    fmt.Println(Latest(Version{1, 26}, Version{1, 27}))
}

In Production

The module's go line does not protect you from everything. While trying the inference change I noticed this: a small program that uses the generic Double function without type arguments in a struct literal, in a channel send and in a conversion compiled on Go 1.27.4 with a go 1.26.0 line and printed 4 6 14. The same program did not compile with Go 1.26.11:

package main

import "fmt"

func Double[T ~int | ~float64](v T) T { return v * 2 }

type S struct{ F func(int) int }

func main() {
    s := S{F: Double}
    ch := make(chan func(int) int, 1)
    ch <- Double
    fmt.Println(s.F(2), (<-ch)(3), func(int) int(Double)(7))
}
GOTOOLCHAIN=go1.26.11 go build .
# infer
./main.go:10:12: cannot use generic function Double without instantiation
./main.go:12:8: cannot use generic function Double without instantiation
./main.go:13:47: cannot use generic function Double without instantiation

Generic methods depend on the go line; this inference change does not. If your code has to compile with Go 1.26 too, because Go supports the two most recent major releases at a time, build it in CI with the oldest release you support as well. In this series I build the code with Go 1.26.11 separately at the end of every post.

The speed of generic code can vary with the type argument. Generic functions called with pointer or interface types work through the dictionary, so they are sometimes slower than their hand-written counterparts. Before moving a loop that runs very often to generic code, measure it with the benchmarks we will see in post 12.

Iterators do not have to be single-use, but some are. Store.Books() starts from the beginning every time it is called; an iterator that reads from a file or the network may not be walkable a second time. Say which kind you return in the doc comment.

Common Pitfalls

Forgetting ~. That is the error in Guess First: a constraint that says int64 leaves out named types such as Money. If your domain uses named types, you almost always want ~ in constraints.

any everywhere. If you write func Process[T any](v T) and then do a type assertion like v.(Book) inside, you have gained nothing generic; either write a real constraint or use a plain parameter.

Ignoring the result of yield. An iterator like countdown in the lab causes a runtime panic when the loop ends with break. Wrap every yield call in an if.

Relying on map order. maps.Keys returns an iterator, but its order is random; if a JSON response or a test needs a fixed order, use slices.Sorted or keep the order separately as ByAuthor does.

Making everything generic. A generic function written for a single type makes the reader's job harder and gains nothing. Turning it generic is easy once a second use appears.

A Small Experiment

★☆☆ Delete the ~ marks from the Integer constraint in labs/06-generics and see on which line and with which message the compiler stops. Then find what you need to add to the constraint so that Sum can also be called with a []float64.

★★☆ Add an InStock() iter.Seq[Book] method to catalog.Store that yields the books in stock, and use it to implement a GET /api/books?inStock=true parameter. Hint: you will need to turn the result into a slice with slices.Collect before passing it to paging.Of.

★★★ Turn Store.Books() into a pull iterator with iter.Pull and take two books one after the other with next(). What happens if you forget to call stop()? Read the documentation of the iter package and write the answer in a comment.

The language block ends here. In the next post we move on to Go's best-known side: goroutines and channels. Using Bookstore's stock check, we will see how Go's scheduler spreads goroutines over threads, why that differs from async/await in C#, unbuffered and buffered channels, select and the "all goroutines are asleep" deadlock.