Go Generics: Write Once, Use With Any Type

The Copy Paste Problem

You write a function to find the maximum in a slice of integers. Works great. Then you need the same for floats. Copy, paste, change int to float64. Then strings. Copy, paste again. Now you have three functions doing the exact same thing.
A month later, you find a bug in your max logic. You fix it in one function. Forget the other two. Bugs lurk in duplicated code.
This is the problem generics solve. One function. Any type. Zero duplication.

Life Before Generics

Before Go 1.18, developers had two options for type flexible code:
Option 1: interface{} (any)
go
// Works but loses type safety func Max(a, b interface{}) interface{} { // How do you compare interface{}? // Runtime type assertions everywhere }
Option 2: Code Generation
go
// Write templates, generate code for each type // Bloated binary, complex build process
Both approaches had significant drawbacks. Generics give us type safety with flexibility.
Go blog diagram 1

Go blog diagram 1

The Universal Adapter

Think of it like this: Generics are like a universal power adapter. The adapter doesn't care if you're plugging in a phone, laptop, or camera. It has a standard interface that works with anything following the rules. Similarly, a generic function doesn't care if you pass integers, strings, or custom types. It works with anything that meets its constraints.

Your First Generic Function

A generic function uses type parameters in square brackets before the regular parameters.
go
// Filename: first_generic.go package main import "fmt" // Max returns the larger of two values // T is a type parameter that must be ordered (comparable with < >) // Why: Single function works for int, float64, string, etc. func Max[T int | float64 | string](a, b T) T { if a > b { return a } return b } func main() { // Works with integers fmt.Println("Max int:", Max(10, 20)) // Works with floats fmt.Println("Max float:", Max(3.14, 2.71)) // Works with strings (lexicographic comparison) fmt.Println("Max string:", Max("apple", "banana")) }
Expected Output:
Max int: 20 Max float: 3.14 Max string: banana
Go blog diagram 2

Go blog diagram 2

Understanding Constraints

Constraints define what operations a type must support. Go provides built in constraints in the constraints package.
go
// Filename: constraints_example.go package main import ( "fmt" "golang.org/x/exp/constraints" ) // Sum adds all numbers in a slice // constraints.Integer includes all int types (int8, int16, int32, int64, uint, etc.) func Sum[T constraints.Integer | constraints.Float](numbers []T) T { var total T for _, n := range numbers { total += n } return total } // Find returns index of first occurrence, -1 if not found // comparable is built-in: types that support == and != func Find[T comparable](slice []T, target T) int { for i, v := range slice { if v == target { return i } } return -1 } func main() { ints := []int{1, 2, 3, 4, 5} fmt.Println("Sum of ints:", Sum(ints)) floats := []float64{1.1, 2.2, 3.3} fmt.Println("Sum of floats:", Sum(floats)) names := []string{"Alice", "Bob", "Charlie"} fmt.Println("Find Bob:", Find(names, "Bob")) }
Expected Output:
Sum of ints: 15 Sum of floats: 6.6 Find Bob: 1

Common Constraints

ConstraintDescriptionExample Types
anyAny typeAll types
comparableTypes with == and !=int, string, pointers
constraints.OrderedTypes with < > <= >=int, float, string
constraints.IntegerAll integer typesint, int8, uint, etc.
constraints.FloatAll float typesfloat32, float64

Creating Custom Constraints

Define your own constraints using interfaces.
go
// Filename: custom_constraints.go package main import "fmt" // Stringer constraint: must have String() method type Stringer interface { String() string } // Printable: must be comparable and have String() // Why: Combine multiple constraints with interface type Printable interface { comparable String() string } // Person implements Stringer type Person struct { Name string Age int } func (p Person) String() string { return fmt.Sprintf("%s (%d)", p.Name, p.Age) } // PrintAll prints any slice of Stringer types func PrintAll[T Stringer](items []T) { for _, item := range items { fmt.Println(item.String()) } } func main() { people := []Person{ {Name: "Alice", Age: 30}, {Name: "Bob", Age: 25}, } PrintAll(people) }
Expected Output:
Alice (30)
Bob (25)

Generic Types (Not Just Functions)

Create generic data structures that work with any type.
go
// Filename: generic_types.go package main import "fmt" // Stack is a generic stack data structure // Why: Same logic works for stack of any type type Stack[T any] struct { items []T } // Push adds an item to the stack func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } // Pop removes and returns the top item func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T // Zero value for type T return zero, false } index := len(s.items) - 1 item := s.items[index] s.items = s.items[:index] return item, true } // Peek returns the top item without removing 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 } func main() { // Stack of integers intStack := Stack[int]{} intStack.Push(1) intStack.Push(2) intStack.Push(3) if val, ok := intStack.Pop(); ok { fmt.Println("Popped int:", val) } // Stack of strings stringStack := Stack[string]{} stringStack.Push("Hello") stringStack.Push("World") if val, ok := stringStack.Peek(); ok { fmt.Println("Peek string:", val) } }
Expected Output:
Popped int: 3 Peek string: World
Go blog diagram 3

Go blog diagram 3

Real World Example: Generic Cache

go
// Filename: generic_cache.go package main import ( "fmt" "sync" "time" ) // CacheItem holds value with expiration type CacheItem[T any] struct { Value T ExpiresAt time.Time } // Cache is a generic thread-safe cache type Cache[K comparable, V any] struct { mu sync.RWMutex items map[K]CacheItem[V] } // NewCache creates a new cache func NewCache[K comparable, V any]() *Cache[K, V] { return &Cache[K, V]{ items: make(map[K]CacheItem[V]), } } // Set adds or updates an item with TTL func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = CacheItem[V]{ Value: value, ExpiresAt: time.Now().Add(ttl), } } // Get retrieves an item if not expired func (c *Cache[K, V]) Get(key K) (V, bool) { c.mu.RLock() defer c.mu.RUnlock() item, exists := c.items[key] if !exists { var zero V return zero, false } if time.Now().After(item.ExpiresAt) { var zero V return zero, false } return item.Value, true } func main() { // Cache with string keys and int values intCache := NewCache[string, int]() intCache.Set("count", 42, time.Minute) if val, ok := intCache.Get("count"); ok { fmt.Println("Count:", val) } // Cache with int keys and struct values type User struct { Name string Email string } userCache := NewCache[int, User]() userCache.Set(1, User{Name: "Alice", Email: "alice@example.com"}, time.Hour) if user, ok := userCache.Get(1); ok { fmt.Println("User:", user.Name) } }
Expected Output:
Count: 42
User: Alice

Type Inference

Go often infers type parameters from arguments.
go
// Explicit type parameter result := Max[int](10, 20) // Inferred from arguments (preferred when obvious) result := Max(10, 20) // Go infers T = int
Type inference makes generic code cleaner while maintaining type safety.

When Generics Make Sense

Go blog diagram 4

Go blog diagram 4

Use Generics When:
  • Writing data structures (lists, maps, trees)
  • Utility functions that work on multiple types
  • Type safety is important
  • You want compile time guarantees
Skip Generics When:
  • Only one type is needed
  • interface{} with type assertions works fine
  • Simplicity matters more than type safety

Common Patterns

Pattern 1: Map/Transform

go
// Transform applies function to each element func Map[T, 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 numbers := []int{1, 2, 3} doubled := Map(numbers, func(n int) int { return n * 2 })

Pattern 2: Filter

go
// Filter keeps elements that pass the test func Filter[T any](slice []T, test func(T) bool) []T { result := make([]T, 0) for _, v := range slice { if test(v) { result = append(result, v) } } return result } // Usage evens := Filter(numbers, func(n int) bool { return n%2 == 0 })

Pattern 3: Reduce

go
// Reduce combines all elements into single value func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U { result := initial for _, v := range slice { result = fn(result, v) } return result } // Usage sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n })

Mistakes to Avoid

Mistake 1: Overusing generics
go
// WRONG: Generics for single type func ProcessUsers[T User](users []T) { ... } // RIGHT: Just use the concrete type func ProcessUsers(users []User) { ... }
Mistake 2: Ignoring constraints
go
// WRONG: any allows types that can't be added func Sum[T any](a, b T) T { return a + b // Compile error: + not defined for any } // RIGHT: Use proper constraint func Sum[T constraints.Integer | constraints.Float](a, b T) T { return a + b }
Mistake 3: Complex type parameter names
go
// WRONG: Confusing names func Process[InputType, OutputType, TransformType any](...) { ... } // RIGHT: Simple, conventional names func Process[T, U, V any](...) { ... } // Or descriptive when helpful: [K comparable, V any] for map-like structures

Performance Considerations

ApproachType SafetyPerformanceUse Case
GenericsCompile timeExcellentMost cases
interface{}RuntimeGoodUnknown types
Type specificN/ABestSingle type
Generics are compiled to specialized code for each type used. No runtime overhead compared to type specific functions.

What You Learned

You now understand that:
  • Type parameters go in square brackets: func Name[T any](...)
  • Constraints define allowed types: comparable, Ordered, custom interfaces
  • Generic types work for structs too:
    type Stack[T any] struct {...}
  • Type inference reduces verbosity: Often don't need explicit type arguments
  • Use generics for reusable logic: Data structures, utilities, transformations

Your Next Steps

  • Build: Create a generic linked list with all standard operations
  • Read Next: Explore the slices and maps packages in Go's standard library
  • Experiment: Refactor duplicate code in your projects to use generics
Generics eliminate duplication without sacrificing type safety. They're not for every function, but for the right problems, they're exactly the tool you need. Write it once, use it everywhere.
All Blogs
Tags:golanggenericstype-parametersconstraints