Avatar

Golang: Generics in Go

Introduction

Go 1.18 introduced one of the most significant features in the language's history: generics. After years of debate and careful design, Go developers can now write type-safe, reusable code without sacrificing the language's simplicity and performance characteristics.

What Are Generics?

Generics allow you to write functions and data structures that work with multiple types while maintaining type safety. Instead of writing separate functions for each type or using interface, you can write a single generic function that works with any type that satisfies certain constraints.

Before Generics (Go 1.17 and earlier):

before-generics.go
1// Separate functions for different types 2func MinInt(a, b int) int { 3 if a < b { 4 return a 5 } 6 return b 7} 8 9func MinFloat64(a, b float64) float64 { 10 if a < b { 11 return a 12 } 13 return b 14} 15 16// Or using interface{} (not type-safe) 17func Min(a, b interface{}) interface{} { 18 // Type assertions needed, runtime panics possible 19 // Complex implementation required 20}

With Generics (Go 1.18+):

with-generics.go
1// Single generic function for all comparable types 2func Min[T comparable](a, b T) T { 3 if a < b { 4 return a 5 } 6 return b 7} 8 9// Usage 10result1 := Min(10, 20) // Works with int 11result2 := Min(3.14, 2.71) // Works with float64 12result3 := Min("hello", "world") // Works with string

Type Parameters and Constraints

Type parameters are defined in square brackets and can have constraints that specify what operations are allowed on the type.

Basic Type Parameters:

type-parameters.go
1// T is a type parameter 2func Identity[T any](value T) T { 3 return value 4} 5 6// Multiple type parameters 7func Pair[T, U any](first T, second U) (T, U) { 8 return first, second 9} 10 11// Usage 12id := Identity[int](42) 13name := Identity[string]("Go") 14pair := Pair[int, string](1, "one")

Type Constraints:

type-constraints.go
1// Using built-in constraints 2import "golang.org/x/exp/constraints" 3 4func Add[T constraints.Ordered](a, b T) T { 5 return a + b 6} 7 8// Custom constraint 9type Numeric interface { 10 int | int8 | int16 | int32 | int64 | 11 uint | uint8 | uint16 | uint32 | uint64 | 12 float32 | float64 13} 14 15func Multiply[T Numeric](a, b T) T { 16 return a * b 17}

Generic Data Structures

Generics shine when creating reusable data structures like stacks, queues, and maps.

Generic Stack Implementation:

stack.go
1type Stack[T any] struct { 2 items []T 3} 4 5func NewStack[T any]() *Stack[T] { 6 return &Stack[T]{ 7 items: make([]T, 0), 8 } 9} 10 11func (s *Stack[T]) Push(item T) { 12 s.items = append(s.items, item) 13} 14 15func (s *Stack[T]) Pop() (T, bool) { 16 if len(s.items) == 0 { 17 var zero T 18 return zero, false 19 } 20 21 index := len(s.items) - 1 22 item := s.items[index] 23 s.items = s.items[:index] 24 return item, true 25} 26 27func (s *Stack[T]) IsEmpty() bool { 28 return len(s.items) == 0 29} 30 31// Usage 32intStack := NewStack[int]() 33intStack.Push(1) 34intStack.Push(2) 35 36stringStack := NewStack[string]() 37stringStack.Push("hello") 38stringStack.Push("world")

Real-World Example: Generic Cache

Here's a practical example of a generic in-memory cache that can store any type of value.

cache.go
1package main 2 3import ( 4 "fmt" 5 "sync" 6 "time" 7) 8 9type CacheItem[T any] struct { 10 Value T 11 ExpiresAt time.Time 12} 13 14type Cache[K comparable, V any] struct { 15 mu sync.RWMutex 16 items map[K]CacheItem[V] 17} 18 19func NewCache[K comparable, V any]() *Cache[K, V] { 20 return &Cache[K, V]{ 21 items: make(map[K]CacheItem[V]), 22 } 23} 24 25func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) { 26 c.mu.Lock() 27 defer c.mu.Unlock() 28 29 c.items[key] = CacheItem[V]{ 30 Value: value, 31 ExpiresAt: time.Now().Add(ttl), 32 } 33} 34 35func (c *Cache[K, V]) Get(key K) (V, bool) { 36 c.mu.RLock() 37 defer c.mu.RUnlock() 38 39 item, exists := c.items[key] 40 if !exists || time.Now().After(item.ExpiresAt) { 41 var zero V 42 return zero, false 43 } 44 45 return item.Value, true 46} 47 48func main() { 49 // String cache 50 userCache := NewCache[int, string]() 51 userCache.Set(1, "Alice", 5*time.Minute) 52 53 if name, found := userCache.Get(1); found { 54 fmt.Printf("User: %s 55", name) 56 } 57 58 // Struct cache 59 type User struct { 60 Name string 61 Email string 62 } 63 64 structCache := NewCache[string, User]() 65 structCache.Set("user1", User{"Bob", "bob@example.com"}, 10*time.Minute) 66 67 if user, found := structCache.Get("user1"); found { 68 fmt.Printf("User: %+v 69", user) 70 } 71}

Best Practices

✅ Do:

  • Use generics when you need type safety with multiple types
  • Prefer specific constraints over 'any' when possible
  • Use meaningful type parameter names (T, K, V are fine for simple cases)
  • Consider using generics for data structures and algorithms

❌ Don't:

  • Use generics just because you can - they add complexity
  • Over-constrain your type parameters unnecessarily
  • Use generics for simple cases where interface would suffice
  • Create overly complex generic hierarchies

Conclusion

Generics in Go provide a powerful way to write reusable, type-safe code while maintaining the language's core principles of simplicity and performance. They're particularly useful for data structures, algorithms, and utility functions that need to work with multiple types. While they add some complexity, when used judiciously, they can significantly improve code quality and reduce duplication.

As you start using generics in your Go projects, remember to balance the benefits of type safety and code reuse with the added complexity they introduce. Start simple, and gradually explore more advanced patterns as you become comfortable with the syntax and concepts.