Go Generics: What Are Those Squiggly Lines?
The Generic Problem
Before Go 1.18 (2022), if you wanted to write a function that worked with different types, you had two choices:
- Write duplicate code for each type
- Use
interface{}and lose type safety
Generics solve this by letting you write one function that works with many types while keeping full type safety.
// Before generics: duplicate code
func MinInt(a, b int) int {
if a < b {
return a
}
return b
}
func MinFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}
// Or use interface{} and lose type safety
func Min(a, b interface{}) interface{} {
// Can't compare without type assertions
// Returns interface{}, requires type assertion at call site
// No compile-time type checking
}
Basic Syntax: Type Parameters
The syntax [T any] declares a type parameter:
// T is a type parameter that can be any type
func Min[T any](a, b T) T {
// Problem: can't use < with 'any'
// We need a constraint!
}
Think of [T any] like a function parameter, but for types:
- Regular function:
func Print(s string)-sis a value parameter - Generic function:
func Print[T any](v T)-Tis a type parameter
Constraints: Specifying What Types Can Do
Most generic functions need constraints - rules about what operations the type supports:
// cmp.Ordered is a constraint that allows <, >, ==
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// Now this works!
x := Min(1, 2) // T is int
y := Min(1.5, 2.7) // T is float64
z := Min("hello", "bye") // T is string
Common Built-in Constraints
// any - any type (alias for interface{})
func Print[T any](v T) {
fmt.Println(v) // Can print anything
}
// comparable - types that support == and !=
func Contains[T comparable](slice []T, value T) bool {
for _, v := range slice {
if v == value { // Requires comparable
return true
}
}
return false
}
// cmp.Ordered - types that support <, >, <=, >=
// Includes: integers, floats, strings
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
The Tilde (~): Understanding Underlying Types
The ~ (tilde) means “any type with this underlying type”:
// Without tilde: only works with exact type []int
func Sum1[T []int](values T) int {
sum := 0
for _, v := range values {
sum += v
}
return sum
}
// With tilde: works with any type whose underlying type is []int
func Sum2[T ~[]int](values T) int {
sum := 0
for _, v := range values {
sum += v
}
return sum
}
// Usage
type MyInts []int
nums := []int{1, 2, 3}
custom := MyInts{1, 2, 3}
Sum1(nums) // Works
Sum1(custom) // Error: MyInts is not []int
Sum2(nums) // Works
Sum2(custom) // Works! MyInts has underlying type []int
When to use ~:
- Use it when you want to support custom types built on basic types
- The Go standard library uses it extensively (e.g.,
slices.Sort[S ~[]E, E cmp.Ordered])
Real-World Example: Generic Stack
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // Return zero value if empty
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
return s.items[len(s.items)-1], true
}
// Usage
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
val, ok := intStack.Pop() // val is int, not interface{}
stringStack := Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
s, ok := stringStack.Pop() // s is string
Multiple Type Parameters
Functions can have multiple type parameters:
// Map transforms a slice from one type to another
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Usage: convert []int to []string
nums := []int{1, 2, 3}
strs := Map(nums, func(n int) string {
return fmt.Sprintf("num: %d", n)
})
// strs = []string{"num: 1", "num: 2", "num: 3"}
// Convert []string to []int (length)
words := []string{"cat", "elephant", "dog"}
lengths := Map(words, func(s string) int {
return len(s)
})
// lengths = []int{3, 8, 3}
Creating Custom Constraints
You can define your own constraints using interfaces:
// Numeric allows any integer or float type
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Average[T Numeric](values []T) float64 {
if len(values) == 0 {
return 0
}
var sum T
for _, v := range values {
sum += v
}
return float64(sum) / float64(len(values))
}
// Works with any numeric type
Average([]int{1, 2, 3, 4}) // 2.5
Average([]float64{1.5, 2.5, 3.5}) // 2.5
Method Constraints
Constraints can require specific methods:
// Stringer requires a String() method
type Stringer interface {
String() string
}
func PrintAll[T Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String()) // Guaranteed to exist
}
}
type Person struct {
Name string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s (%d)", p.Name, p.Age)
}
people := []Person{{"Alice", 30}, {"Bob", 25}}
PrintAll(people) // Works because Person implements Stringer
Type Inference
Go can usually infer type parameters from arguments:
func Identity[T any](v T) T {
return v
}
// Explicit type parameter
x := Identity[int](42)
// Type inference (preferred)
y := Identity(42) // T inferred as int
z := Identity("hello") // T inferred as string
When you need explicit types:
// Creating an empty slice
func MakeSlice[T any](size int) []T {
return make([]T, size)
}
// Must specify: can't infer T from an int
nums := MakeSlice[int](10) // explicit
nums := MakeSlice(10) // can't infer T
Standard Library Examples
slices Package
// Sort any ordered slice
slices.Sort([]int{3, 1, 2}) // [1, 2, 3]
slices.Sort([]string{"c", "a", "b"}) // [a, b, c]
// Find elements
found := slices.Contains([]int{1, 2, 3}, 2) // true
index := slices.Index([]string{"a", "b"}, "b") // 1
// Compare slices
equal := slices.Equal([]int{1, 2}, []int{1, 2}) // true
maps Package
// Clone a map
original := map[string]int{"a": 1, "b": 2}
copy := maps.Clone(original)
// Get keys
keys := maps.Keys(original) // []string (order not guaranteed)
// Copy maps
dest := make(map[string]int)
maps.Copy(dest, original)
Performance Considerations: Generics vs Heap Allocation
Generics have performance implications related to how Go manages memory. Understanding the trade-offs helps you make informed decisions.
The Stack vs Heap Problem
Go allocates variables on the stack (fast, automatic cleanup) or heap (slower, garbage collected) based on escape analysis. Generics can affect these decisions.
Pre-generics approach with interface{}:
// Using interface{} - values escape to heap
func ProcessInterface(v interface{}) interface{} {
// v is on the heap (needed to support any type)
return v
}
// Usage
result := ProcessInterface(42) // 42 boxed, allocated on heap
num := result.(int) // Type assertion needed
With generics:
// Generic version - can stay on stack
func ProcessGeneric[T any](v T) T {
// T is known at compile time
// Can potentially stay on stack
return v
}
// Usage
result := ProcessGeneric(42) // May stay on stack, no boxing
Measuring the Difference
Let’s benchmark the actual impact:
// interface_bench_test.go
func BenchmarkInterface(b *testing.B) {
for i := 0; i < b.N; i++ {
v := ProcessInterface(i)
_ = v.(int)
}
}
func BenchmarkGeneric(b *testing.B) {
for i := 0; i < b.N; i++ {
v := ProcessGeneric(i)
_ = v
}
}
Typical results:
BenchmarkInterface-8 50000000 25.3 ns/op 8 B/op 1 allocs/op
BenchmarkGeneric-8 500000000 2.4 ns/op 0 B/op 0 allocs/op
The generic version is ~10x faster and allocates no memory because the compiler generates specialized code for ProcessGeneric[int].
How Generics Are Compiled
Go uses monomorphization for generics - it generates specialized code for each concrete type:
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// Compiler generates something like:
func Min_int(a, b int) int { // For Min[int]
if a < b { return a }
return b
}
func Min_string(a, b string) string { // For Min[string]
if a < b { return a }
return b
}
Trade-offs:
- Fast execution - no boxing, no type assertions, inlineable
- Stack allocation - small values can stay on stack
- Larger binary - one copy per type used
- Longer compile time - generating multiple versions
Real-World Example: Container Performance
// Using interface{} slice
type InterfaceSlice struct {
items []interface{}
}
func (s *InterfaceSlice) Add(item interface{}) {
s.items = append(s.items, item) // Boxing allocation
}
func (s *InterfaceSlice) Get(i int) interface{} {
return s.items[i] // Returns interface
}
// Using generic slice
type GenericSlice[T any] struct {
items []T
}
func (s *GenericSlice[T]) Add(item T) {
s.items = append(s.items, item) // No boxing
}
func (s *GenericSlice[T]) Get(i int) T {
return s.items[i] // Returns T directly
}
Benchmark:
func BenchmarkInterfaceSlice(b *testing.B) {
s := &InterfaceSlice{}
for i := 0; i < b.N; i++ {
s.Add(i)
v := s.Get(0).(int)
_ = v
}
}
func BenchmarkGenericSlice(b *testing.B) {
s := &GenericSlice[int]{}
for i := 0; i < b.N; i++ {
s.Add(i)
v := s.Get(0)
_ = v
}
}
Results:
BenchmarkInterfaceSlice-8 20000000 65.2 ns/op 48 B/op 2 allocs/op
BenchmarkGenericSlice-8 50000000 28.1 ns/op 32 B/op 1 allocs/op
Generic version is 2x faster and allocates less memory.
Escape Analysis with Generics
Use go build -gcflags="-m" to see allocation decisions:
// check_escape.go
package main
func WithInterface(v interface{}) interface{} {
return v
}
func WithGeneric[T any](v T) T {
return v
}
func main() {
WithInterface(42)
WithGeneric(42)
}
Run escape analysis:
go build -gcflags="-m -m" check_escape.go 2>&1 | grep -E "WithInterface|WithGeneric"
Output:
./main.go:3:19: parameter v leaks to heap
./main.go:13:16: 42 escapes to heap // interface{} causes heap allocation
./main.go:7:18: parameter v does not escape
# No escape for generic version - stays on stack!
When Generics Don’t Help Performance
1. Large or pointer types:
type BigStruct struct {
data [1024]byte
}
// Both allocate on heap - struct is too large for stack
func ProcessInterface(v interface{}) { /* ... */ }
func ProcessGeneric[T any](v T) { /* ... */ }
big := BigStruct{}
ProcessInterface(big) // Heap
ProcessGeneric(big) // Also heap (too large)
// Both pointer types already on heap
ProcessInterface(&big) // Heap
ProcessGeneric(&big) // Also heap
2. Type stored in interface field:
type Container[T any] struct {
value interface{} // T goes to interface{} anyway
}
// Loses generic performance benefit
c := Container[int]{value: 42} // Still boxes to heap
3. Method calls on interface:
func Process[T io.Reader](r T) {
r.Read(buffer) // T converted to interface internally
}
Binary Size Impact
Generics increase binary size because each type instantiation generates code:
// This function used with 3 types:
func Process[T any](v T) T { /* 50 lines of code */ }
Process[int](1)
Process[string]("hello")
Process[float64](3.14)
// Compiler generates ~150 lines (3 × 50)
// vs ~50 lines with one interface{} version
Measure binary size:
# Without generics
go build -o binary-old main.go
ls -lh binary-old # 8.2 MB
# With generics (5 different types used)
go build -o binary-new main.go
ls -lh binary-new # 8.7 MB (500 KB larger)
For most applications, this trade-off is acceptable—the performance gains outweigh the binary size increase.
Best Practices for Performance
1. Use generics for value types and small structs:
// Good: small values benefit most
func Min[T cmp.Ordered](a, b T) T { ... }
func Contains[T comparable](slice []T, v T) bool { ... }
2. Avoid unnecessary interface conversions:
// Bad: loses generic benefit
func Process[T any](v T) interface{} {
return v // Boxing happens here
}
// Good: keeps type information
func Process[T any](v T) T {
return v
}
3. Profile before optimizing:
// Benchmark both approaches
func BenchmarkYourCode(b *testing.B) {
for i := 0; i < b.N; i++ {
// Your code here
}
}
Run with allocation tracking:
go test -bench=. -benchmem
4. Consider interface{} for huge polymorphic collections:
// Many different types in one slice?
// interface{} might be simpler
var mixed []interface{} = []interface{}{42, "hello", 3.14, true}
// vs maintaining multiple generic slices
ints := []int{42}
strings := []string{"hello"}
floats := []float64{3.14}
bools := []bool{true}
Summary: When to Choose What
| Use Case | Best Choice | Why |
|---|---|---|
| Small value types (int, float, etc) | Generics | No boxing, stack allocation |
| Type-safe containers | Generics | Better performance, type safety |
| Many concrete types | Generics | Generated code amortizes |
| Very few uses | interface{} | Smaller binary |
| Large structs | Either | Both likely heap-allocate |
| Pointers | Either | Already on heap |
| Reflection needed | interface{} | Generics don’t help reflection |
| Plugin systems | interface{} | Runtime polymorphism needed |
The key insight: Generics shine for small value types where boxing overhead matters. They provide both type safety and performance improvements over interface{}.
When NOT to Use Generics
1. When interfaces are clearer:
// Don't need generics - interface is clearer
type Writer interface {
Write([]byte) (int, error)
}
func SaveData(w Writer, data []byte) error {
_, err := w.Write(data)
return err
}
2. When you only need one type:
// Don't need generics for single use case
func SumInts(values []int) int { // Clear and simple
sum := 0
for _, v := range values {
sum += v
}
return sum
}
// Generic version adds complexity without benefit
func Sum[T ~int](values []T) T { // Unnecessary
var sum T
for _, v := range values {
sum += v
}
return sum
}
3. When reflection is actually appropriate:
// JSON marshaling needs to inspect any type at runtime
// Generics can't help here - need reflection
func ToJSON[T any](v T) ([]byte, error) {
// Still uses reflection internally
return json.Marshal(v)
}
Common Gotchas
1. Can’t use methods on type parameters without constraints
// Doesn't work
func CallString[T any](v T) string {
return v.String() // Error: T doesn't have String method
}
// Add constraint
type Stringer interface {
String() string
}
func CallString[T Stringer](v T) string {
return v.String() // Now it works
}
2. Zero values require explicit declaration
func FirstOrZero[T any](slice []T) T {
if len(slice) == 0 {
return nil // Error: nil is not valid for all T
}
return slice[0]
}
// Use zero value
func FirstOrZero[T any](slice []T) T {
if len(slice) == 0 {
var zero T // Zero value of T
return zero
}
return slice[0]
}
3. Type parameters can’t be used in some contexts
// Can't use type parameter to create variables
func BadFunc[T any]() {
var x T // This is fine
T{1, 2} // Can't use T as constructor
}
// Can't use in type switch
func BadSwitch[T any](v T) {
switch v.(type) { // Error: v is not interface
case int:
// ...
}
}
Best Practices
- Start without generics - Add them when you see duplication
- Prefer simple constraints - Use
any,comparable,cmp.Orderedwhen possible - Use meaningful names -
Tis fine for simple cases, but useKey,Value,Elementfor clarity - Don’t fight the type system - If generics feel awkward, maybe interfaces are better
// Good: clear and simple
func Max[T cmp.Ordered](a, b T) T { ... }
// Good: meaningful names in complex cases
func MapKeys[Key comparable, Value any](m map[Key]Value) []Key { ... }
// Bad: overengineered
func Process[T any, U any, V any](a T, b U, fn func(T, U) V) V { ... }
Conclusion
Go generics provide type-safe code reuse without sacrificing clarity. They’re most valuable for:
- Container types (stacks, queues, trees)
- Data transformations (map, filter, reduce)
- Algorithms (sort, search, min/max)
- Utility functions used across many types
The syntax takes getting used to, but once it clicks, generics become a natural part of writing idiomatic Go. Use them where they add clarity, skip them where they don’t.