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):
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+):
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:
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:
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:
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.
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.