Go Generics Deep Dive: From Type Parameters to Real-World Applications
— golang, generics, type-safety, programming, go1.18, data-structures, performance — 26 min read
After years of heated debates, Go finally embraced generics in version 1.18. This wasn't just adding angle brackets to the language - it was a carefully designed system that maintains Go's simplicity while solving real type-safety problems.
But generics in Go are different. They're not Java generics with type erasure, nor C++ templates with code bloat. Go implemented a unique approach using type parameters and constraints that feels natural to Go developers while providing compile-time type safety and zero runtime overhead.
This deep dive explores every aspect of Go generics - from basic syntax to advanced patterns, performance implications, and real-world applications. You'll learn not just how to use generics, but when and why to use them.
Understanding Type Parameters
The Problem Generics Solve
Before generics, Go developers faced a trilemma:
// Option 1: Write duplicate code for each typefunc MinInt(a, b int) int { if a < b { return a } return b}
func MinFloat64(a, b float64) float64 { if a < b { return a } return b}
func MinString(a, b string) string { if a < b { return a } return b}
// Option 2: Use interface{} and lose type safetyfunc Min(a, b interface{}) interface{} { // Runtime type assertions needed switch v := a.(type) { case int: if b, ok := b.(int); ok && v < b { return v } case float64: if b, ok := b.(float64); ok && v < b { return v } // ... more cases } return a}
// Option 3: Code generation (go generate)//go:generate genny -in=min.go -out=gen-min.go gen "T=int,float64,string"
Basic Generic Syntax
With generics, we write the logic once:
// Generic function with type parameter Tfunc Min[T constraints.Ordered](a, b T) T { if a < b { return a } return b}
// Usage - type inference makes it cleanresult1 := Min(5, 3) // Min[int](5, 3)result2 := Min(3.14, 2.71) // Min[float64](3.14, 2.71)result3 := Min("hello", "go") // Min[string]("hello", "go")
// Explicit type specification when neededresult4 := Min[float32](3.14, 2.71)
Multiple Type Parameters
Functions and types can have multiple type parameters:
// Generic function with multiple type parametersfunc Transform[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}
// Usagenumbers := []int{1, 2, 3, 4, 5}strings := Transform(numbers, func(n int) string { return fmt.Sprintf("Number: %d", n)})// strings = ["Number: 1", "Number: 2", ...]
// Generic type with multiple parameterstype Pair[T, U any] struct { First T Second U}
// Methods on generic typesfunc (p Pair[T, U]) Swap() Pair[U, T] { return Pair[U, T]{First: p.Second, Second: p.First}}
// Usagep1 := Pair[string, int]{"age", 30}p2 := p1.Swap() // Pair[int, string]{30, "age"}
Type Constraints In-Depth
The Constraint System
Type constraints define what operations are allowed on type parameters:
// Basic constraint using interfacetype Numeric interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64}
// The ~ (tilde) means "underlying type"// This allows custom types based on these primitivestype MyInt int // MyInt satisfies ~int
func Sum[T Numeric](values []T) T { var sum T // Zero value of T for _, v := range values { sum += v // + operator available due to constraint } return sum}
Built-in Constraints
The constraints
package provides useful predefined constraints:
import "golang.org/x/exp/constraints"
// constraints.Ordered - types that support <, <=, >, >=func Sort[T constraints.Ordered](slice []T) { sort.Slice(slice, func(i, j int) bool { return slice[i] < slice[j] })}
// constraints.Integer - all integer typesfunc Abs[T constraints.Integer | constraints.Float](x T) T { if x < 0 { return -x } return x}
// constraints.Signed - signed integers// constraints.Unsigned - unsigned integers// constraints.Float - floating point types
Custom Constraints with Methods
Constraints can require specific methods:
// Constraint requiring String() methodtype Stringer interface { String() string}
// Generic function using method constraintfunc PrintAll[T Stringer](items []T) { for _, item := range items { fmt.Println(item.String()) }}
// More complex constraint combining methods and typestype Number interface { ~int | ~int64 | ~float64 String() string // Also requires String method}
// Constraint with multiple methodstype Comparable[T any] interface { Compare(T) int Equal(T) bool}
// Using the constraintfunc FindMax[T Comparable[T]](items []T) T { if len(items) == 0 { var zero T return zero } max := items[0] for _, item := range items[1:] { if item.Compare(max) > 0 { max = item } } return max}
Constraint Embedding and Composition
// Embedding constraintstype OrderedStringer interface { constraints.Ordered fmt.Stringer}
// Union constraints with methodstype ComparableNumber interface { ~int | ~int64 | ~float64 Compare(ComparableNumber) int}
// Recursive constraints for self-referential typestype Node[T any] interface { Value() T Children() []Node[T]}
// Type lists with additional requirementstype Container[T any] interface { ~[]T | ~map[string]T Len() int}
Generic Data Structures
Generic Stack Implementation
// Thread-safe generic stacktype Stack[T any] struct { mu sync.RWMutex items []T}
func NewStack[T any]() *Stack[T] { return &Stack[T]{ items: make([]T, 0), }}
func (s *Stack[T]) Push(item T) { s.mu.Lock() defer s.mu.Unlock() s.items = append(s.items, item)}
func (s *Stack[T]) Pop() (T, bool) { s.mu.Lock() defer s.mu.Unlock() var zero T if len(s.items) == 0 { 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) { s.mu.RLock() defer s.mu.RUnlock() var zero T if len(s.items) == 0 { return zero, false } return s.items[len(s.items)-1], true}
func (s *Stack[T]) Size() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.items)}
// UsageintStack := NewStack[int]()intStack.Push(42)intStack.Push(17)value, ok := intStack.Pop() // 17, true
stringStack := NewStack[string]()stringStack.Push("hello")stringStack.Push("world")
Generic Binary Search Tree
// Generic BST with ordered constrainttype TreeNode[T constraints.Ordered] struct { Value T Left *TreeNode[T] Right *TreeNode[T]}
type BST[T constraints.Ordered] struct { root *TreeNode[T] size int}
func NewBST[T constraints.Ordered]() *BST[T] { return &BST[T]{}}
func (bst *BST[T]) Insert(value T) { bst.root = bst.insertNode(bst.root, value) bst.size++}
func (bst *BST[T]) insertNode(node *TreeNode[T], value T) *TreeNode[T] { if node == nil { return &TreeNode[T]{Value: value} } if value < node.Value { node.Left = bst.insertNode(node.Left, value) } else if value > node.Value { node.Right = bst.insertNode(node.Right, value) } return node}
func (bst *BST[T]) Find(value T) bool { return bst.findNode(bst.root, value)}
func (bst *BST[T]) findNode(node *TreeNode[T], value T) bool { if node == nil { return false } if value == node.Value { return true } else if value < node.Value { return bst.findNode(node.Left, value) } else { return bst.findNode(node.Right, value) }}
func (bst *BST[T]) InOrder() []T { result := make([]T, 0, bst.size) bst.inOrderTraversal(bst.root, &result) return result}
func (bst *BST[T]) inOrderTraversal(node *TreeNode[T], result *[]T) { if node == nil { return } bst.inOrderTraversal(node.Left, result) *result = append(*result, node.Value) bst.inOrderTraversal(node.Right, result)}
// Usagetree := NewBST[int]()tree.Insert(5)tree.Insert(3)tree.Insert(7)tree.Insert(1)tree.Insert(9)
fmt.Println(tree.Find(7)) // truefmt.Println(tree.InOrder()) // [1 3 5 7 9]
Generic LRU Cache
// Generic LRU cache with any comparable key typetype LRUCache[K comparable, V any] struct { capacity int items map[K]*list.Element order *list.List mu sync.Mutex}
type cacheEntry[K comparable, V any] struct { key K value V}
func NewLRUCache[K comparable, V any](capacity int) *LRUCache[K, V] { return &LRUCache[K, V]{ capacity: capacity, items: make(map[K]*list.Element), order: list.New(), }}
func (c *LRUCache[K, V]) Get(key K) (V, bool) { c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.items[key]; ok { c.order.MoveToFront(elem) entry := elem.Value.(cacheEntry[K, V]) return entry.value, true } var zero V return zero, false}
func (c *LRUCache[K, V]) Put(key K, value V) { c.mu.Lock() defer c.mu.Unlock() if elem, ok := c.items[key]; ok { c.order.MoveToFront(elem) entry := elem.Value.(cacheEntry[K, V]) entry.value = value elem.Value = entry return } if c.order.Len() >= c.capacity { oldest := c.order.Back() if oldest != nil { c.order.Remove(oldest) entry := oldest.Value.(cacheEntry[K, V]) delete(c.items, entry.key) } } entry := cacheEntry[K, V]{key: key, value: value} elem := c.order.PushFront(entry) c.items[key] = elem}
// Usagecache := NewLRUCache[string, int](3)cache.Put("a", 1)cache.Put("b", 2)cache.Put("c", 3)cache.Put("d", 4) // Evicts "a"
val, ok := cache.Get("a") // 0, false (evicted)val, ok = cache.Get("b") // 2, true (moved to front)
Type Inference and Instantiation
How Type Inference Works
Go's type inference for generics is sophisticated but predictable:
// Type inference from argumentsfunc Identity[T any](x T) T { return x}
// Inference examplesv1 := Identity(42) // T inferred as intv2 := Identity("hello") // T inferred as stringv3 := Identity([]int{1, 2}) // T inferred as []int
// Type inference with multiple parametersfunc 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}
// T inferred from first arg, U from function return typedoubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2})
// Partial type inferencefunc Pair[T, U any](t T, u U) (T, U) { return t, u}
// Cannot partially specify types - must be all or nothing// p := Pair[int](5, "hello") // Error!p := Pair[int, string](5, "hello") // OKp2 := Pair(5, "hello") // OK - both inferred
Instantiation and Type Checking
// Generic type instantiationtype Container[T any] struct { items []T}
// Explicit instantiationvar c1 Container[int] // Type is Container[int]c2 := Container[string]{} // Initialized Container[string]c3 := new(Container[float64]) // *Container[float64]
// Type alias with genericstype IntContainer = Container[int]type StringList = []string
// Cannot create generic type aliases// type MyContainer[T any] = Container[T] // Error!
// Generic interfacestype Processor[T any] interface { Process(T) T}
// Implementing generic interfacetype Doubler[T constraints.Integer | constraints.Float] struct{}
func (d Doubler[T]) Process(x T) T { return x * 2}
// Interface satisfactionvar _ Processor[int] = Doubler[int]{}var _ Processor[float64] = Doubler[float64]{}
Type Parameter Constraints in Practice
// Constraint inference from usagefunc Zero[T any]() T { var zero T return zero}
// Inferred constraints from operationsfunc Add[T any](a, b T) T { // This won't compile - T doesn't guarantee + operator // return a + b // Error! // Need explicit constraint var zero T return zero}
// Correct version with constraintfunc AddCorrect[T constraints.Integer | constraints.Float](a, b T) T { return a + b // Now it works!}
// Type switching with genericsfunc ProcessValue[T any](v T) string { // Type switches work with type parameters switch any(v).(type) { case int: return fmt.Sprintf("Integer: %d", v) case string: return fmt.Sprintf("String: %s", v) case []byte: return fmt.Sprintf("Bytes: %x", v) default: return fmt.Sprintf("Unknown: %v", v) }}
Advanced Generic Patterns
Builder Pattern with Generics
// Generic builder patterntype Builder[T any] struct { value T errors []error actions []func(*T) error}
func NewBuilder[T any](initial T) *Builder[T] { return &Builder[T]{ value: initial, actions: make([]func(*T) error, 0), }}
func (b *Builder[T]) With(action func(*T) error) *Builder[T] { b.actions = append(b.actions, action) return b}
func (b *Builder[T]) Build() (T, error) { for _, action := range b.actions { if err := action(&b.value); err != nil { b.errors = append(b.errors, err) } } if len(b.errors) > 0 { var zero T return zero, fmt.Errorf("build errors: %v", b.errors) } return b.value, nil}
// Usage exampletype User struct { Name string Email string Age int}
user, err := NewBuilder(User{}). With(func(u *User) error { u.Name = "John Doe" return nil }). With(func(u *User) error { u.Email = "john@example.com" if !strings.Contains(u.Email, "@") { return fmt.Errorf("invalid email") } return nil }). With(func(u *User) error { u.Age = 30 if u.Age < 0 || u.Age > 150 { return fmt.Errorf("invalid age") } return nil }). Build()
Option Pattern with Generics
// Generic option patterntype Option[T any] func(*T)
type Server[T any] struct { config T}
func NewServer[T any](opts ...Option[T]) *Server[T] { var config T for _, opt := range opts { opt(&config) } return &Server[T]{config: config}}
// Concrete configuration typetype HTTPConfig struct { Port int Timeout time.Duration MaxConns int}
// Option functionsfunc WithPort(port int) Option[HTTPConfig] { return func(c *HTTPConfig) { c.Port = port }}
func WithTimeout(timeout time.Duration) Option[HTTPConfig] { return func(c *HTTPConfig) { c.Timeout = timeout }}
func WithMaxConnections(max int) Option[HTTPConfig] { return func(c *HTTPConfig) { c.MaxConns = max }}
// Usageserver := NewServer[HTTPConfig]( WithPort(8080), WithTimeout(30*time.Second), WithMaxConnections(100),)
Functional Programming Patterns
// Generic functional utilitiesfunc 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}
func Filter[T any](slice []T, predicate func(T) bool) []T { result := make([]T, 0, len(slice)) for _, v := range slice { if predicate(v) { result = append(result, v) } } return result}
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}
func Compose[T, U, V any](f func(T) U, g func(U) V) func(T) V { return func(t T) V { return g(f(t)) }}
// Pipeline patternfunc Pipeline[T any](initial T, fns ...func(T) T) T { result := initial for _, fn := range fns { result = fn(result) } return result}
// Usage examplesnumbers := []int{1, 2, 3, 4, 5}
// Map examplesquared := Map(numbers, func(x int) int { return x * x })// [1, 4, 9, 16, 25]
// Filter exampleevens := Filter(numbers, func(x int) bool { return x%2 == 0 })// [2, 4]
// Reduce examplesum := Reduce(numbers, 0, func(acc, x int) int { return acc + x })// 15
// Compose exampleaddOne := func(x int) int { return x + 1 }double := func(x int) int { return x * 2 }addOneThenDouble := Compose(addOne, double)result := addOneThenDouble(3) // (3 + 1) * 2 = 8
// Pipeline exampleprocessed := Pipeline(5, func(x int) int { return x + 1 }, func(x int) int { return x * 2 }, func(x int) int { return x - 3 },) // ((5 + 1) * 2) - 3 = 9
Result/Option Types
// Generic Result type for error handlingtype Result[T any] struct { value T err error}
func Ok[T any](value T) Result[T] { return Result[T]{value: value}}
func Err[T any](err error) Result[T] { return Result[T]{err: err}}
func (r Result[T]) IsOk() bool { return r.err == nil}
func (r Result[T]) IsErr() bool { return r.err != nil}
func (r Result[T]) Unwrap() T { if r.err != nil { panic(r.err) } return r.value}
func (r Result[T]) UnwrapOr(defaultValue T) T { if r.err != nil { return defaultValue } return r.value}
func (r Result[T]) Map[U any](fn func(T) U) Result[U] { if r.err != nil { return Err[U](r.err) } return Ok(fn(r.value))}
func (r Result[T]) AndThen[U any](fn func(T) Result[U]) Result[U] { if r.err != nil { return Err[U](r.err) } return fn(r.value)}
// Generic Option typetype Option[T any] struct { value *T}
func Some[T any](value T) Option[T] { return Option[T]{value: &value}}
func None[T any]() Option[T] { return Option[T]{value: nil}}
func (o Option[T]) IsSome() bool { return o.value != nil}
func (o Option[T]) IsNone() bool { return o.value == nil}
func (o Option[T]) Unwrap() T { if o.value == nil { panic("unwrap on None") } return *o.value}
func (o Option[T]) UnwrapOr(defaultValue T) T { if o.value == nil { return defaultValue } return *o.value}
// Usagefunc divide(a, b int) Result[int] { if b == 0 { return Err[int](fmt.Errorf("division by zero")) } return Ok(a / b)}
result := divide(10, 2)if result.IsOk() { fmt.Println("Result:", result.Unwrap()) // Result: 5}
doubled := result.Map(func(x int) int { return x * 2 })fmt.Println(doubled.UnwrapOr(0)) // 10
Performance Implications
Compilation and Binary Size
// Understanding generic instantiation
// This generic function...func GenericMax[T constraints.Ordered](a, b T) T { if a > b { return a } return b}
// ...gets instantiated for each type used:// - GenericMax[int]// - GenericMax[float64]// - GenericMax[string]// Each instantiation creates separate machine code
// Measuring binary size impact// File: main.gopackage main
import "fmt"
// Non-generic versionfunc MaxInt(a, b int) int { if a > b { return a } return b}
// Generic versionfunc Max[T constraints.Ordered](a, b T) T { if a > b { return a } return b}
func main() { // Non-generic: single function fmt.Println(MaxInt(5, 3)) // Generic: creates 3 instantiations fmt.Println(Max(5, 3)) // Max[int] fmt.Println(Max(5.5, 3.3)) // Max[float64] fmt.Println(Max("b", "a")) // Max[string]}
// Build and check size:// go build -o non_generic main_non_generic.go// go build -o generic main_generic.go// ls -lh non_generic generic
Runtime Performance
// Benchmarking generics vs interfacespackage main
import ( "testing")
// Interface-based approachtype Comparable interface { Less(Comparable) bool}
type Int int
func (i Int) Less(other Comparable) bool { return i < other.(Int)}
func MinInterface(a, b Comparable) Comparable { if a.Less(b) { return a } return b}
// Generic approachfunc MinGeneric[T constraints.Ordered](a, b T) T { if a < b { return a } return b}
// Direct approachfunc MinInt(a, b int) int { if a < b { return a } return b}
// Benchmarksfunc BenchmarkInterface(b *testing.B) { for i := 0; i < b.N; i++ { _ = MinInterface(Int(5), Int(3)) }}
func BenchmarkGeneric(b *testing.B) { for i := 0; i < b.N; i++ { _ = MinGeneric(5, 3) }}
func BenchmarkDirect(b *testing.B) { for i := 0; i < b.N; i++ { _ = MinInt(5, 3) }}
// Results (typical):// BenchmarkInterface-8 50000000 25.3 ns/op 16 B/op 1 allocs/op// BenchmarkGeneric-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op// BenchmarkDirect-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op
Memory Allocation Patterns
// Generic slice operations and memoryfunc GrowSlice[T any](slice []T, newSize int) []T { if cap(slice) >= newSize { return slice[:newSize] } // Calculate new capacity (same as runtime) newCap := cap(slice) doubleCap := newCap + newCap if newSize > doubleCap { newCap = newSize } else { if len(slice) < 1024 { newCap = doubleCap } else { for newCap < newSize { newCap += newCap / 4 } } } // Allocate new backing array newSlice := make([]T, newSize, newCap) copy(newSlice, slice) return newSlice}
// Zero-allocation generic pooltype Pool[T any] struct { pool sync.Pool}
func NewPool[T any](newFunc func() T) *Pool[T] { return &Pool[T]{ pool: sync.Pool{ New: func() interface{} { return newFunc() }, }, }}
func (p *Pool[T]) Get() T { return p.pool.Get().(T)}
func (p *Pool[T]) Put(x T) { p.pool.Put(x)}
// Usage - zero allocations in hot pathbufferPool := NewPool(func() []byte { return make([]byte, 1024)})
func ProcessData(data []byte) { buf := bufferPool.Get() defer bufferPool.Put(buf) // Use buf for processing // No allocation if pool has available buffers}
Real-World Applications
Generic HTTP Router
// Type-safe HTTP router with genericstype Handler[T any] func(ctx context.Context, req T) (any, error)
type Router[T any] struct { routes map[string]Handler[T] mu sync.RWMutex}
func NewRouter[T any]() *Router[T] { return &Router[T]{ routes: make(map[string]Handler[T]), }}
func (r *Router[T]) Handle(pattern string, handler Handler[T]) { r.mu.Lock() defer r.mu.Unlock() r.routes[pattern] = handler}
func (r *Router[T]) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mu.RLock() handler, ok := r.routes[req.URL.Path] r.mu.RUnlock() if !ok { http.NotFound(w, req) return } // Decode request var payload T if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Call handler response, err := handler(req.Context(), payload) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Encode response w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response)}
// Usagetype CreateUserRequest struct { Name string `json:"name"` Email string `json:"email"`}
type UserResponse struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"`}
router := NewRouter[CreateUserRequest]()
router.Handle("/users", func(ctx context.Context, req CreateUserRequest) (any, error) { // Validate if req.Name == "" || req.Email == "" { return nil, fmt.Errorf("name and email required") } // Create user (simplified) user := UserResponse{ ID: generateID(), Name: req.Name, Email: req.Email, } return user, nil})
http.ListenAndServe(":8080", router)
Generic Database Repository
// Generic repository patterntype Entity interface { GetID() string SetID(string)}
type Repository[T Entity] struct { db *sql.DB tableName string columns []string scanFunc func(*sql.Row) (T, error)}
func NewRepository[T Entity](db *sql.DB, tableName string, columns []string, scanFunc func(*sql.Row) (T, error)) *Repository[T] { return &Repository[T]{ db: db, tableName: tableName, columns: columns, scanFunc: scanFunc, }}
func (r *Repository[T]) FindByID(ctx context.Context, id string) (T, error) { query := fmt.Sprintf("SELECT %s FROM %s WHERE id = $1", strings.Join(r.columns, ", "), r.tableName) row := r.db.QueryRowContext(ctx, query, id) return r.scanFunc(row)}
func (r *Repository[T]) FindAll(ctx context.Context) ([]T, error) { query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(r.columns, ", "), r.tableName) rows, err := r.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var results []T for rows.Next() { // Note: This is simplified - real implementation would handle scanning properly var entity T results = append(results, entity) } return results, rows.Err()}
func (r *Repository[T]) Create(ctx context.Context, entity T) error { if entity.GetID() == "" { entity.SetID(generateID()) } placeholders := make([]string, len(r.columns)) for i := range placeholders { placeholders[i] = fmt.Sprintf("$%d", i+1) } query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", r.tableName, strings.Join(r.columns, ", "), strings.Join(placeholders, ", ")) // Execute insert (simplified - would need actual values) _, err := r.db.ExecContext(ctx, query) return err}
// Usage with concrete typetype User struct { ID string Name string Email string}
func (u User) GetID() string { return u.ID }func (u *User) SetID(id string) { u.ID = id }
userRepo := NewRepository[*User]( db, "users", []string{"id", "name", "email"}, func(row *sql.Row) (*User, error) { var u User err := row.Scan(&u.ID, &u.Name, &u.Email) return &u, err },)
user, err := userRepo.FindByID(ctx, "user-123")
Generic Event Bus
// Type-safe event bus with genericstype Event interface { Type() string}
type EventHandler[T Event] func(context.Context, T) error
type EventBus[T Event] struct { handlers map[string][]EventHandler[T] mu sync.RWMutex}
func NewEventBus[T Event]() *EventBus[T] { return &EventBus[T]{ handlers: make(map[string][]EventHandler[T]), }}
func (eb *EventBus[T]) Subscribe(eventType string, handler EventHandler[T]) { eb.mu.Lock() defer eb.mu.Unlock() eb.handlers[eventType] = append(eb.handlers[eventType], handler)}
func (eb *EventBus[T]) Publish(ctx context.Context, event T) error { eb.mu.RLock() handlers := eb.handlers[event.Type()] eb.mu.RUnlock() var wg sync.WaitGroup errors := make(chan error, len(handlers)) for _, handler := range handlers { wg.Add(1) go func(h EventHandler[T]) { defer wg.Done() if err := h(ctx, event); err != nil { errors <- err } }(handler) } wg.Wait() close(errors) // Collect errors var errs []error for err := range errors { errs = append(errs, err) } if len(errs) > 0 { return fmt.Errorf("event handling errors: %v", errs) } return nil}
// Usagetype UserEvent struct { UserID string Action string Timestamp time.Time}
func (e UserEvent) Type() string { return e.Action}
eventBus := NewEventBus[UserEvent]()
// Subscribe to eventseventBus.Subscribe("user.created", func(ctx context.Context, event UserEvent) error { fmt.Printf("User created: %s at %v\n", event.UserID, event.Timestamp) return nil})
eventBus.Subscribe("user.created", func(ctx context.Context, event UserEvent) error { // Send welcome email return sendWelcomeEmail(event.UserID)})
// Publish eventeventBus.Publish(context.Background(), UserEvent{ UserID: "user-123", Action: "user.created", Timestamp: time.Now(),})
Common Pitfalls and Best Practices
Type Inference Limitations
// Pitfall: Type inference doesn't work with type assertionsfunc Process[T any](v interface{}) T { // This won't compile - can't infer T // return v.(T) // Error! // Need to use type parameter explicitly return v.(T)}
// Must specify type explicitlyresult := Process[string]("hello")
// Pitfall: Can't use type parameters in type assertionsfunc Convert[T any](v interface{}) (T, bool) { // This pattern doesn't work as expected result, ok := v.(T) // T is not a type, it's a type parameter return result, ok}
// Solution: Use type switches or reflectionfunc ConvertSafe[T any](v interface{}) (T, bool) { if typed, ok := v.(T); ok { return typed, true } var zero T return zero, false}
Method Set Restrictions
// Pitfall: Pointer vs value receivers with genericstype Container[T any] struct { value T}
// Value receiverfunc (c Container[T]) GetValue() T { return c.value}
// Pointer receiverfunc (c *Container[T]) SetValue(v T) { c.value = v}
// This worksvar c1 Container[int]val := c1.GetValue() // OK - value receiver
// This also worksvar c2 *Container[int] = new(Container[int])c2.SetValue(42) // OK - pointer receiverval2 := c2.GetValue() // OK - pointer has access to value methods
// But be careful with interfacestype Getter[T any] interface { GetValue() T}
var _ Getter[int] = Container[int]{} // OKvar _ Getter[int] = &Container[int]{} // OK
type Setter[T any] interface { SetValue(T)}
// var _ Setter[int] = Container[int]{} // Error! Value doesn't have pointer methodvar _ Setter[int] = &Container[int]{} // OK
Recursive Type Constraints
// Pitfall: Recursive constraints can be trickytype Comparable[T any] interface { CompareTo(T) int}
// This won't work - circular reference// type SelfComparable[T SelfComparable[T]] interface {// CompareTo(T) int// }
// Solution: Use concrete typestype Node[T any] struct { Value T Children []*Node[T] // Self-reference is OK in concrete types}
// Or use interface with methodstype TreeNode interface { GetValue() any GetChildren() []TreeNode}
Performance Best Practices
// Best Practice: Minimize allocations in generic functionsfunc Sum[T constraints.Integer | constraints.Float](values []T) T { var sum T // Use zero value, no allocation for _, v := range values { sum += v } return sum}
// Avoid unnecessary boxingfunc Bad[T any](values []T) []interface{} { result := make([]interface{}, len(values)) for i, v := range values { result[i] = v // Boxing happens here } return result}
// Better: Keep type safetyfunc Good[T any](values []T, fn func(T) T) []T { result := make([]T, len(values)) for i, v := range values { result[i] = fn(v) // No boxing } return result}
// Best Practice: Use constraints to enable optimizationsfunc MinWithConstraint[T constraints.Ordered](a, b T) T { if a < b { // Direct comparison, no interface calls return a } return b}
// Less efficient with interfacefunc MinWithInterface[T interface{ Less(T) bool }](a, b T) T { if a.Less(b) { // Interface method call return a } return b}
When to Use Generics
// Good use cases for generics:
// 1. Data structurestype Stack[T any] struct { items []T}
// 2. Algorithms that work on multiple typesfunc BinarySearch[T constraints.Ordered](arr []T, target T) int { // Implementation}
// 3. Type-safe containerstype SafeMap[K comparable, V any] struct { mu sync.RWMutex m map[K]V}
// 4. Reducing code duplicationfunc Max[T constraints.Ordered](values ...T) T { // One implementation for all ordered types}
// When NOT to use generics:
// 1. When interface{} is actually appropriatefunc PrintJSON(v interface{}) { data, _ := json.Marshal(v) fmt.Println(string(data))}
// 2. When only one type is ever usedfunc ProcessUserData(users []User) { // Specific to User type}
// 3. When reflection is necessary anywayfunc DeepCopy(v interface{}) interface{} { // Needs reflection regardless}
// 4. When it makes code harder to understand// Simple is better than clever
Migration Guide
Converting Existing Code to Generics
// Before: Interface-based approachtype Comparer interface { Compare(Comparer) int}
type IntWrapper int
func (i IntWrapper) Compare(other Comparer) int { o := other.(IntWrapper) if i < o { return -1 } else if i > o { return 1 } return 0}
func FindMaxInterface(items []Comparer) Comparer { if len(items) == 0 { return nil } max := items[0] for _, item := range items[1:] { if item.Compare(max) > 0 { max = item } } return max}
// After: Generic approachfunc FindMax[T constraints.Ordered](items []T) (T, bool) { if len(items) == 0 { var zero T return zero, false } max := items[0] for _, item := range items[1:] { if item > max { max = item } } return max, true}
// Migration benefits:// 1. No type assertions needed// 2. No wrapper types required// 3. Better performance (no interface calls)// 4. Cleaner API
Gradual Migration Strategy
// Step 1: Identify duplicate code patternsfunc SumInts(nums []int) int { sum := 0 for _, n := range nums { sum += n } return sum}
func SumFloat64s(nums []float64) float64 { sum := 0.0 for _, n := range nums { sum += n } return sum}
// Step 2: Create generic versionfunc Sum[T constraints.Integer | constraints.Float](nums []T) T { var sum T for _, n := range nums { sum += n } return sum}
// Step 3: Add compatibility layer during migrationvar SumInts = Sum[int] // Type alias for compatibilityvar SumFloat64s = Sum[float64]
// Step 4: Update call sites gradually// Old code continues to worktotal := SumInts([]int{1, 2, 3})
// New code uses generic versiontotal := Sum([]int{1, 2, 3})
// Step 5: Remove old functions after migration complete
Conclusion
Go generics represent a carefully balanced addition to the language. They provide type safety and code reuse without sacrificing Go's simplicity or runtime performance. The key insights:
- Type parameters enable code reuse while maintaining type safety
- Constraints define capabilities, not just types
- Type inference keeps code clean and readable
- Zero runtime overhead - generics compile to efficient code
- Gradual adoption - use generics where they provide clear value
The best Go generic code doesn't try to be clever - it solves real problems with clear, maintainable solutions. Start with simple use cases like containers and algorithms, then expand as you become comfortable with the patterns.
Remember: generics are a tool, not a requirement. Use them when they make your code cleaner, safer, and more maintainable. The Go community's measured approach to generics means they integrate naturally with existing Go idioms while opening new possibilities for type-safe, reusable code.
For more examples and patterns, check out the Go generics proposal and the experimental packages that showcase generic implementations.