Lesson 9: Generics
Why Generics? The Problem They Solve
Before Go 1.18, you had two ways to write code that worked with multiple types:
// Option 1: interface{} — loses type safety
func Contains(slice []interface{}, val interface{}) bool {
for _, v := range slice {
if v == val { return true }
}
return false
}
items := []interface{}{1, 2, 3} // painful to convert
// Option 2: Code generation — verbose, hard to maintain
Generics give you a third way: type-safe, concise, no code generation needed:
func Contains[T comparable](slice []T, val T) bool {
for _, v := range slice {
if v == val { return true }
}
return false
}
items := []int{1, 2, 3}
Contains(items, 2) // type-safe, no conversion
Write generic functions and types, understand constraints, and know when generics help vs when interfaces are better.
Generic Functions
Type parameters go in square brackets before regular parameters:
func Min[T cmp.Ordered](a, b T) T {
if a < b { return a }
return b
}
// Go infers T from the arguments
x := Min(3, 5) // T = int
y := Min(3.14, 2.71) // T = float64
z := Min("foo", "bar") // T = string
Multiple Type Parameters
func Transform[T, U any](input []T, fn func(T) U) []U {
result := make([]U, len(input))
for i, v := range input {
result[i] = fn(v)
}
return result
}
// Usage
nums := []int{1, 2, 3}
strs := Transform(nums, func(n int) string { return fmt.Sprintf("%d", n) })
// strs = []string{"1", "2", "3"}
Type Inference Rules
Go infers type arguments from function arguments. If all type params can be inferred, you skip the brackets:
Min(1, 2) // T inferred from args
Min[float64](1, 2.5) // explicit: int and float64 conflict
Constraints — Restricting Type Parameters
A constraint is an interface that defines which types a type parameter can accept.
any — All Types
func PrintAll[T any](s []T) { for _, v := range s { fmt.Println(v) } }
comparable — Supports == and !=
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m { keys = append(keys, k) }
return keys
}
Custom Union Constraints
// Restrict to numeric types only
type Number interface {
int | int64 | float64
}
func Sum[T Number](vals []T) T {
var total T
for _, v := range vals { total += v }
return total
}
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4.0
// Sum([]string{"a", "b"}) — COMPILE ERROR: string doesn't satisfy Number
Approximation Constraint (~)
Use ~ to match any type whose underlying type is in the union:
type MyInt int
type StrictInt interface { int } // matches only int, NOT MyInt
type FlexInt interface { ~int } // matches int AND MyInt (underlying type)
Generic Types (Structs)
You can parameterize structs, not just functions:
type Set[T comparable] struct {
data map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{data: make(map[T]struct{})}
}
func (s *Set[T]) Add(v T) {
s.data[v] = struct{}{}
}
func (s *Set[T]) Contains(v T) bool {
_, ok := s.data[v]
return ok
}
func (s *Set[T]) Len() int {
return len(s.data)
}
Usage is type-safe — you can't add a string to a Set[int]:
intSet := NewSet[int]()
intSet.Add(1)
intSet.Add(2)
// intSet.Add("hello") — COMPILE ERROR
strSet := NewSet[string]()
strSet.Add("hello")
// strSet.Add(42) — COMPILE ERROR
Note that the constraint T comparable is an interface. Generics build on Go's interface system — constraints ARE interfaces.
Generics vs Interfaces — When to Use Which
This is the most important judgment call in Go generics:
Use GenericsUse Interfaces Container types: Set, Stack, QueueBehavior abstraction: Reader, Writer Algorithms: Sort, Filter, Map, ReducePolymorphism at runtime Replacing identical code across typesDependency injection, testing mocks Type-safe utilities: Must, Ptr, ZeroWhen implementation differs per type
If every implementation would be identical except for the type, use generics. If each type would have different logic, use an interface.
What NOT to Do with Generics
Don't use generics when an interface is simpler:
// OVER-ENGINEERED: generic for behavior that should be an interface
func WriteAll[T interface{ Write([]byte) (int, error) }](w T, data [][]byte) error { ... }
// BETTER: just use the io.Writer interface
func WriteAll(w io.Writer, data [][]byte) error { ... }
Don't use generics as a replacement for good interface design. If you find yourself writing a generic constraint that looks exactly like an existing interface, just use the interface.
Common Pitfalls
Can't Switch on Type Parameter
func Stringify[T any](v T) string {
switch v.(type) { // ERROR: cannot type switch on type parameter
case int: ...
}
}
Workaround: convert to any first:
func Stringify[T any](v T) string {
switch val := any(v).(type) { // ok: convert to interface{}
case int: return fmt.Sprintf("%d", val)
case string: return val
}
return fmt.Sprintf("%v", v)
}
Methods Can't Have Type Parameters
func (s *Set[T]) Map[U any]() *Set[U] { ... } // ERROR
Only the receiver's type and package-level functions can be generic. If you need this, make it a package-level function:
func MapSet[T, U comparable](s *Set[T], fn func(T) U) *Set[U] { ... }
Practice
Write a generic Min[T cmp.Ordered](a, b T) T and Max[T cmp.Ordered](a, b T) T. Test with int, float64, and string. Verify type safety — try calling Min(myStruct, otherStruct) and observe the compile error.
Implement a generic Stack[T any] with methods Push(v T), Pop() (T, bool), and Len() int. Use a slice internally. Write a test that pushes 3 ints, pops them, and verifies order (LIFO). Then do the same with strings.
Suppose you're writing a cloud-native metrics library. You need to support multiple backends: Prometheus, StatsD, and a no-op for testing. Each backend has a different implementation. Which should you use — generics or an interface? Write a short explanation of your choice.
Quick Check
What's Next?
You've now covered all the major Go features. The final lesson: Reflection & unsafe — the internals deep-dive. Understanding how reflect works under the hood, what unsafe.Pointer is for, and when (rarely) to use them.
Ask Questions
Not sure whether your use case calls for generics or interfaces? Describe it and I'll help you decide.
1. Q1: What does the ~ (tilde) do in a constraint?
2. Q2: Why can't Go methods have their own type parameters?
3. Q3: When should you use an interface instead of generics?