Go From Zero, Part 9: Generics Without the Headache
This is Part 9 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 8: sync, context, and Concurrency Patterns is the place to catch up.
In Part 8 we built worker pools and pipelines. You may have noticed something along the way: we wrote functions that worked only for int or only for string, even when the logic didn’t care about the type at all.
For most of Go’s history, the answer was “write it twice” or “use interface{} and lose type safety”. Go 1.18 added generics (officially, type parameters), and they’ve since become a normal part of everyday Go. Today you’ll learn how they work, and just as importantly, when to leave them alone.
The problem generics solve
Say you want the largest value in a slice. Without generics:
func MaxInt(nums []int) int { /* ... */ }
func MaxFloat(nums []float64) float64 { /* ... */ }
func MaxString(strs []string) string { /* ... */ }
Same logic, three times. The old workaround, func Max(items []interface{}) interface{}, compiles, but now the caller has to cast the result back and the compiler can’t catch mistakes.
Your first generic function
package main
import (
"cmp"
"fmt"
)
// T is a type parameter. cmp.Ordered means "any type that supports < and >".
func Max[T cmp.Ordered](items []T) T {
if len(items) == 0 {
var zero T
return zero
}
best := items[0]
for _, v := range items[1:] {
if v > best {
best = v
}
}
return best
}
func main() {
fmt.Println(Max([]int{3, 9, 2})) // 9
fmt.Println(Max([]float64{1.5, 0.2})) // 1.5
fmt.Println(Max([]string{"kiwi", "apple"})) // kiwi
fmt.Println(Max[int](nil)) // 0
}
Read [T cmp.Ordered] as “for any type T that is ordered”. When you call Max([]int{...}), Go infers that T is int, and the result is a real int. No casting, no runtime checks, and passing a slice of something unordered is a compile error.

Notice var zero T. That’s the idiomatic way to get the zero value of a type parameter, since you don’t know if it’s 0, "" or something else.
Constraints: saying what a type must support
The part in brackets after the type parameter is its constraint. Constraints are just interfaces, with one extra power: they can list types.
package main
import "fmt"
// Number allows any of these types, or types built on them (the ~ part).
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
type Rupees int64 // a named type whose underlying type is int64
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{0.5, 0.25})) // 0.75
fmt.Println(Sum([]Rupees{100, 250})) // 350, works thanks to ~int64
}
The ~ means “this type or any type whose underlying type is this”. Without ~int64, our Rupees type wouldn’t be allowed.
Constraints you’ll use most:
any: no requirements at all. Works with every type, but you can only do things every type supports, like assigning and passing around.comparable: types you can compare with==and!=. Required for map keys.cmp.Ordered: types that support<,>, and so on. Numbers and strings.- Your own interfaces with methods, like
fmt.Stringer.
Generic helpers you’ll actually write
Here are three small functions I’ve seen in nearly every Go codebase since generics landed:
package main
import (
"fmt"
"strings"
)
// Map transforms every element.
func Map[T, U any](items []T, f func(T) U) []U {
out := make([]U, 0, len(items))
for _, v := range items {
out = append(out, f(v))
}
return out
}
// Filter keeps elements where keep returns true.
func Filter[T any](items []T, keep func(T) bool) []T {
var out []T
for _, v := range items {
if keep(v) {
out = append(out, v)
}
}
return out
}
// GroupBy buckets items by a key.
func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
out := make(map[K][]T)
for _, v := range items {
k := key(v)
out[k] = append(out[k], v)
}
return out
}
type User struct {
Name string
City string
Age int
}
func main() {
users := []User{
{"Asha", "Pune", 29},
{"Ravi", "Delhi", 17},
{"Meera", "Pune", 34},
}
names := Map(users, func(u User) string { return strings.ToUpper(u.Name) })
fmt.Println(names) // [ASHA RAVI MEERA]
adults := Filter(users, func(u User) bool { return u.Age >= 18 })
fmt.Println(len(adults)) // 2
byCity := GroupBy(users, func(u User) string { return u.City })
fmt.Println(len(byCity["Pune"])) // 2
}
Before writing your own, check the standard library. The slices and maps packages (which you met in Part 3) are themselves generic and already include slices.Contains, slices.Index, slices.SortFunc, slices.Max, maps.Keys and much more.
Generic types
Types can have type parameters too. A classic example is a stack:
package main
import "fmt"
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
func main() {
var s Stack[string] // zero value is ready to use
s.Push("first")
s.Push("second")
v, ok := s.Pop()
fmt.Println(v, ok, s.Len()) // second true 1
nums := &Stack[int]{}
nums.Push(42)
fmt.Println(nums.Pop()) // 42 true
}
Notice how Pop returns (T, bool), the same comma-ok idea we’ve used for maps and channels. Also notice var s Stack[string] works with no constructor, because the zero value of the struct is useful. That’s a design habit worth copying.
A thread-safe generic cache
Let’s combine this with Part 8’s mutex to build something genuinely useful:
package main
import (
"fmt"
"sync"
)
type Cache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}
func (c *Cache[K, V]) Set(key K, val V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = val
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
type Product struct {
Name string
Price int
}
func main() {
products := NewCache[int, Product]()
products.Set(1, Product{"Keyboard", 2499})
if p, ok := products.Get(1); ok {
fmt.Println(p.Name, p.Price) // Keyboard 2499
}
sessions := NewCache[string, []string]()
sessions.Set("abc", []string{"read", "write"})
perms, _ := sessions.Get("abc")
fmt.Println(perms) // [read write]
}
One implementation, fully type-safe for any key and value types.
One limitation to know: methods can’t have their own extra type parameters. func (c *Cache[K, V]) Convert[U any]() isn’t allowed. Write a regular function instead.
When NOT to use generics
This is where good Go developers stand out. Generics are a tool, not a goal. Some honest guidelines:
1. Start with concrete types. Write the function for the one type you need. If you later write the same logic a second and third time for other types, then make it generic.
2. If an interface works, use the interface. If your function only calls methods, like w.Write(...), take an io.Writer. Generics shine when you need to work with the values themselves (compare them, store them, return them with their real type), especially in containers and slice/map helpers.
3. Don’t build Java-style abstractions. Generic repositories, generic services and three layers of type parameters make code harder to read. Go’s style values clarity over cleverness.
4. Readability is the test. If a teammate needs more than a few seconds to understand a signature, simplify it.
The Go team’s own guidance sums it up: write code, don’t design types.
Best practices from this part
- Use the standard library first:
slices,mapsandcmpcover most needs. - Name type parameters simply:
Tfor a single type,K, Vfor keys and values,Efor slice elements. - Pick the loosest constraint that works:
anyif you only move values around,comparablefor map keys,cmp.Orderedfor sorting. - Use
~in constraints so named types liketype Rupees int64work too. - Make generic types’ zero values useful, or provide a
New...constructor when they need setup (like a map). - Reach for generics on the second or third duplicate, not the first.
Recap
- Type parameters go in square brackets:
func F[T any](x T). - Constraints are interfaces that say what a type must support.
~Tincludes named types built onT.- Generic types like
Stack[T]andCache[K, V]give you reusable, type-safe containers. - Prefer interfaces when you only need behaviour, and generics when you need the values themselves.
Try this: write a generic Reduce[T, A any](items []T, initial A, f func(A, T) A) A and use it to compute both the total age and the longest name from the users slice above.
In Part 10, we’ll make sure all this code actually works. Testing in Go is built in, simple and fast, and it’s one of the language’s real strengths.
Where to go from here
⬅️ Previous: Part 8: sync, context, and Concurrency Patterns
➡️ Next: Part 10: Testing, Benchmarks, and Fuzzing
The full Go From Zero series
- Part 1: Why Go, Setting It Up, and Your First Program
- Part 2: Variables, Types, Control Flow, and Functions
- Part 3: Arrays, Slices, Maps, and Structs
- Part 4: Methods, Interfaces, and Composition
- Part 5: Error Handling the Go Way
- Part 6: Packages, Modules, and Project Layout
- Part 7: Goroutines, Channels, and select
- Part 8: sync, context, and Concurrency Patterns
- Part 9: Generics Without the Headache (you are here)
- Part 10: Testing, Benchmarks, and Fuzzing
- Part 11: Best Practices for Production-Ready Go
- Part 12: Building a REST API with the Standard Library
If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.
Releted Posts
Go From Zero, Part 8: sync, context, and Concurrency Patterns
This is Part 8 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 7: Goroutines, Channels, and select is the place to catch up.
Read moreGo From Zero, Part 7: Goroutines, Channels, and select
This is Part 7 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 6: Packages, Modules, and Project Layout is the place to catch up.
Read moreGo From Zero, Part 6: Packages, Modules, and Project Layout
This is Part 6 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 5: Error Handling the Go Way is the place to catch up.
Read more