Go Generics Design Patterns
Go Concurrency Patterns Series: ← Graceful Shutdown | Series Overview | Distributed Tracing → What Are Go Generics? Go 1.18 introduced generics (type parameters), enabling type-safe, reusable code without sacrificing performance. This opens up new possibilities for implementing classic design patterns with compile-time type safety. Key Features: Type Parameters: Functions and types that work with any type Constraints: Restrict type parameters to specific interfaces Type Inference: Compiler deduces type arguments automatically Zero Runtime Cost: No boxing/unboxing like interface{} Real-World Use Cases Collections: Type-safe lists, maps, sets without reflection Algorithms: Generic sort, filter, map operations Data Structures: Stacks, queues, trees with any element type Caching: Generic cache implementations Functional Patterns: Map, filter, reduce with type safety Concurrent Patterns: Type-safe worker pools and pipelines Generic Data Structures Generic Stack package main import ( "fmt" "sync" ) // Stack is a generic LIFO data structure type Stack[T any] struct { items []T mu sync.RWMutex } 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() if len(s.items) == 0 { var zero T 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() if len(s.items) == 0 { var zero T 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) } func main() { // Integer stack intStack := NewStack[int]() intStack.Push(1) intStack.Push(2) intStack.Push(3) if val, ok := intStack.Pop(); ok { fmt.Printf("Popped: %d\n", val) // 3 } // String stack strStack := NewStack[string]() strStack.Push("hello") strStack.Push("world") if val, ok := strStack.Peek(); ok { fmt.Printf("Peek: %s\n", val) // world } } Generic Queue package main import ( "fmt" "sync" ) // Queue is a generic FIFO data structure type Queue[T any] struct { items []T mu sync.RWMutex } func NewQueue[T any]() *Queue[T] { return &Queue[T]{ items: make([]T, 0), } } func (q *Queue[T]) Enqueue(item T) { q.mu.Lock() defer q.mu.Unlock() q.items = append(q.items, item) } func (q *Queue[T]) Dequeue() (T, bool) { q.mu.Lock() defer q.mu.Unlock() if len(q.items) == 0 { var zero T return zero, false } item := q.items[0] q.items = q.items[1:] return item, true } func (q *Queue[T]) IsEmpty() bool { q.mu.RLock() defer q.mu.RUnlock() return len(q.items) == 0 } func main() { queue := NewQueue[string]() queue.Enqueue("first") queue.Enqueue("second") if item, ok := queue.Dequeue(); ok { fmt.Println(item) // first } } Generic Set package main import ( "fmt" "sync" ) // Set is a generic collection of unique elements type Set[T comparable] struct { items map[T]struct{} mu sync.RWMutex } func NewSet[T comparable]() *Set[T] { return &Set[T]{ items: make(map[T]struct{}), } } func (s *Set[T]) Add(item T) { s.mu.Lock() defer s.mu.Unlock() s.items[item] = struct{}{} } func (s *Set[T]) Remove(item T) { s.mu.Lock() defer s.mu.Unlock() delete(s.items, item) } func (s *Set[T]) Contains(item T) bool { s.mu.RLock() defer s.mu.RUnlock() _, exists := s.items[item] return exists } func (s *Set[T]) Size() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.items) } func (s *Set[T]) Items() []T { s.mu.RLock() defer s.mu.RUnlock() items := make([]T, 0, len(s.items)) for item := range s.items { items = append(items, item) } return items } // Union returns a new set with elements from both sets func (s *Set[T]) Union(other *Set[T]) *Set[T] { result := NewSet[T]() s.mu.RLock() for item := range s.items { result.Add(item) } s.mu.RUnlock() other.mu.RLock() for item := range other.items { result.Add(item) } other.mu.RUnlock() return result } // Intersection returns a new set with common elements func (s *Set[T]) Intersection(other *Set[T]) *Set[T] { result := NewSet[T]() s.mu.RLock() defer s.mu.RUnlock() other.mu.RLock() defer other.mu.RUnlock() for item := range s.items { if _, exists := other.items[item]; exists { result.Add(item) } } return result } func main() { set1 := NewSet[int]() set1.Add(1) set1.Add(2) set1.Add(3) set2 := NewSet[int]() set2.Add(2) set2.Add(3) set2.Add(4) union := set1.Union(set2) fmt.Println("Union:", union.Items()) // [1 2 3 4] intersection := set1.Intersection(set2) fmt.Println("Intersection:", intersection.Items()) // [2 3] } Generic Cache Pattern package main import ( "fmt" "sync" "time" ) // CacheItem holds cached value with expiration type CacheItem[V any] struct { Value V Expiration time.Time } // Cache is a generic thread-safe cache with expiration type Cache[K comparable, V any] struct { items map[K]CacheItem[V] mu sync.RWMutex ttl time.Duration } func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] { cache := &Cache[K, V]{ items: make(map[K]CacheItem[V]), ttl: ttl, } // Start cleanup goroutine go cache.cleanup() return cache } func (c *Cache[K, V]) Set(key K, value V) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = CacheItem[V]{ Value: value, Expiration: time.Now().Add(c.ttl), } } 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 } // Check expiration if time.Now().After(item.Expiration) { var zero V return zero, false } return item.Value, true } func (c *Cache[K, V]) Delete(key K) { c.mu.Lock() defer c.mu.Unlock() delete(c.items, key) } func (c *Cache[K, V]) cleanup() { ticker := time.NewTicker(c.ttl) defer ticker.Stop() for range ticker.C { c.mu.Lock() now := time.Now() for key, item := range c.items { if now.After(item.Expiration) { delete(c.items, key) } } c.mu.Unlock() } } func main() { // String -> User cache type User struct { ID int Name string } cache := NewCache[string, User](5 * time.Second) cache.Set("user1", User{ID: 1, Name: "Alice"}) cache.Set("user2", User{ID: 2, Name: "Bob"}) if user, ok := cache.Get("user1"); ok { fmt.Printf("Found: %+v\n", user) } // Wait for expiration time.Sleep(6 * time.Second) if _, ok := cache.Get("user1"); !ok { fmt.Println("Cache expired") } } Generic Repository Pattern package main import ( "errors" "fmt" "sync" ) // Entity is a constraint for types with an ID type Entity interface { GetID() string } // User implements Entity type User struct { ID string Name string Email string } func (u User) GetID() string { return u.ID } // Product implements Entity type Product struct { ID string Name string Price float64 } func (p Product) GetID() string { return p.ID } // Repository is a generic CRUD interface type Repository[T Entity] interface { Create(item T) error Read(id string) (T, error) Update(item T) error Delete(id string) error List() []T } // InMemoryRepository is a generic in-memory implementation type InMemoryRepository[T Entity] struct { items map[string]T mu sync.RWMutex } func NewInMemoryRepository[T Entity]() *InMemoryRepository[T] { return &InMemoryRepository[T]{ items: make(map[string]T), } } func (r *InMemoryRepository[T]) Create(item T) error { r.mu.Lock() defer r.mu.Unlock() id := item.GetID() if _, exists := r.items[id]; exists { return errors.New("item already exists") } r.items[id] = item return nil } func (r *InMemoryRepository[T]) Read(id string) (T, error) { r.mu.RLock() defer r.mu.RUnlock() item, exists := r.items[id] if !exists { var zero T return zero, errors.New("item not found") } return item, nil } func (r *InMemoryRepository[T]) Update(item T) error { r.mu.Lock() defer r.mu.Unlock() id := item.GetID() if _, exists := r.items[id]; !exists { return errors.New("item not found") } r.items[id] = item return nil } func (r *InMemoryRepository[T]) Delete(id string) error { r.mu.Lock() defer r.mu.Unlock() if _, exists := r.items[id]; !exists { return errors.New("item not found") } delete(r.items, id) return nil } func (r *InMemoryRepository[T]) List() []T { r.mu.RLock() defer r.mu.RUnlock() items := make([]T, 0, len(r.items)) for _, item := range r.items { items = append(items, item) } return items } func main() { // User repository userRepo := NewInMemoryRepository[User]() userRepo.Create(User{ID: "1", Name: "Alice", Email: "[email protected]"}) userRepo.Create(User{ID: "2", Name: "Bob", Email: "[email protected]"}) if user, err := userRepo.Read("1"); err == nil { fmt.Printf("User: %+v\n", user) } // Product repository productRepo := NewInMemoryRepository[Product]() productRepo.Create(Product{ID: "p1", Name: "Laptop", Price: 999.99}) productRepo.Create(Product{ID: "p2", Name: "Mouse", Price: 29.99}) products := productRepo.List() fmt.Printf("Products: %+v\n", products) } Generic Builder Pattern package main import "fmt" // Builder is a generic builder pattern type Builder[T any] struct { build func() T } func NewBuilder[T any](buildFunc func() T) *Builder[T] { return &Builder[T]{build: buildFunc} } func (b *Builder[T]) Build() T { return b.build() } // Fluent builder for complex types type HTTPRequest struct { Method string URL string Headers map[string]string Body string } type HTTPRequestBuilder struct { req HTTPRequest } func NewHTTPRequestBuilder() *HTTPRequestBuilder { return &HTTPRequestBuilder{ req: HTTPRequest{ Headers: make(map[string]string), }, } } func (b *HTTPRequestBuilder) Method(method string) *HTTPRequestBuilder { b.req.Method = method return b } func (b *HTTPRequestBuilder) URL(url string) *HTTPRequestBuilder { b.req.URL = url return b } func (b *HTTPRequestBuilder) Header(key, value string) *HTTPRequestBuilder { b.req.Headers[key] = value return b } func (b *HTTPRequestBuilder) Body(body string) *HTTPRequestBuilder { b.req.Body = body return b } func (b *HTTPRequestBuilder) Build() HTTPRequest { return b.req } // Generic fluent builder type FluentBuilder[T any] struct { value T } func NewFluentBuilder[T any](initial T) *FluentBuilder[T] { return &FluentBuilder[T]{value: initial} } func (b *FluentBuilder[T]) Apply(fn func(T) T) *FluentBuilder[T] { b.value = fn(b.value) return b } func (b *FluentBuilder[T]) Build() T { return b.value } func main() { // HTTP Request builder req := NewHTTPRequestBuilder(). Method("POST"). URL("https://api.example.com/users"). Header("Content-Type", "application/json"). Body(`{"name": "Alice"}`). Build() fmt.Printf("Request: %+v\n", req) // Generic fluent builder result := NewFluentBuilder(0). Apply(func(n int) int { return n + 10 }). Apply(func(n int) int { return n * 2 }). Apply(func(n int) int { return n - 5 }). Build() fmt.Printf("Result: %d\n", result) // 15 } Generic Option Pattern package main import ( "fmt" "time" ) // Option is a generic option function type Option[T any] func(*T) // Server configuration type ServerConfig struct { Host string Port int Timeout time.Duration MaxConns int ReadTimeout time.Duration WriteTimeout time.Duration } // Option functions func WithHost[T interface{ Host string }](host string) Option[T] { return func(c *T) { c.Host = host } } func WithPort[T interface{ Port int }](port int) Option[T] { return func(c *T) { c.Port = port } } func WithTimeout[T interface{ Timeout time.Duration }](timeout time.Duration) Option[T] { return func(c *T) { c.Timeout = timeout } } // Generic constructor with options func NewWithOptions[T any](initial T, opts ...Option[T]) T { for _, opt := range opts { opt(&initial) } return initial } // Server-specific options func ServerWithHost(host string) Option[ServerConfig] { return func(c *ServerConfig) { c.Host = host } } func ServerWithPort(port int) Option[ServerConfig] { return func(c *ServerConfig) { c.Port = port } } func ServerWithTimeout(timeout time.Duration) Option[ServerConfig] { return func(c *ServerConfig) { c.Timeout = timeout } } func NewServer(opts ...Option[ServerConfig]) ServerConfig { config := ServerConfig{ Host: "localhost", Port: 8080, Timeout: 30 * time.Second, MaxConns: 100, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } for _, opt := range opts { opt(&config) } return config } func main() { server := NewServer( ServerWithHost("0.0.0.0"), ServerWithPort(9000), ServerWithTimeout(60*time.Second), ) fmt.Printf("Server config: %+v\n", server) } Generic Result Type package main import ( "errors" "fmt" ) // Result represents either a value or an error type 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] { var zero T return Result[T]{value: zero, 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, error) { return r.value, r.err } func (r Result[T]) UnwrapOr(defaultValue T) T { if r.IsErr() { return defaultValue } return r.value } // Map transforms the value if Ok func (r Result[T]) Map(fn func(T) T) Result[T] { if r.IsErr() { return r } return Ok(fn(r.value)) } // FlatMap chains operations func FlatMap[T any, U any](r Result[T], fn func(T) Result[U]) Result[U] { if r.IsErr() { return Err[U](r.err) } return fn(r.value) } // Example usage func divide(a, b int) Result[int] { if b == 0 { return Err[int](errors.New("division by zero")) } return Ok(a / b) } func main() { // Success case result1 := divide(10, 2) if value, err := result1.Unwrap(); err == nil { fmt.Printf("Result: %d\n", value) // 5 } // Error case result2 := divide(10, 0) value := result2.UnwrapOr(-1) fmt.Printf("Result with default: %d\n", value) // -1 // Chaining operations result3 := divide(20, 2). Map(func(n int) int { return n * 2 }). Map(func(n int) int { return n + 5 }) if value, err := result3.Unwrap(); err == nil { fmt.Printf("Chained result: %d\n", value) // 25 } } Generic Pipeline Pattern package main import ( "fmt" ) // Pipeline represents a chain of transformations type Pipeline[T any] struct { stages []func(T) T } func NewPipeline[T any]() *Pipeline[T] { return &Pipeline[T]{ stages: make([]func(T) T, 0), } } func (p *Pipeline[T]) Add(stage func(T) T) *Pipeline[T] { p.stages = append(p.stages, stage) return p } func (p *Pipeline[T]) Execute(input T) T { result := input for _, stage := range p.stages { result = stage(result) } return result } // Generic filter, map, reduce func Filter[T any](items []T, predicate func(T) bool) []T { result := make([]T, 0) for _, item := range items { if predicate(item) { result = append(result, item) } } return result } func Map[T any, U any](items []T, mapper func(T) U) []U { result := make([]U, len(items)) for i, item := range items { result[i] = mapper(item) } return result } func Reduce[T any, U any](items []T, initial U, reducer func(U, T) U) U { result := initial for _, item := range items { result = reducer(result, item) } return result } func main() { // Pipeline example pipeline := NewPipeline[int](). Add(func(n int) int { return n * 2 }). Add(func(n int) int { return n + 10 }). Add(func(n int) int { return n / 2 }) result := pipeline.Execute(5) fmt.Printf("Pipeline result: %d\n", result) // 10 // Filter, map, reduce numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} // Filter even numbers evens := Filter(numbers, func(n int) bool { return n%2 == 0 }) fmt.Printf("Evens: %v\n", evens) // Map to squares squares := Map(evens, func(n int) int { return n * n }) fmt.Printf("Squares: %v\n", squares) // Reduce to sum sum := Reduce(squares, 0, func(acc, n int) int { return acc + n }) fmt.Printf("Sum: %d\n", sum) } Generic Worker Pool package main import ( "context" "fmt" "sync" ) // Task represents a generic task type Task[T any, R any] struct { Input T Result chan R } // WorkerPool is a generic worker pool type WorkerPool[T any, R any] struct { workers int tasks chan Task[T, R] process func(T) R wg sync.WaitGroup } func NewWorkerPool[T any, R any](workers int, process func(T) R) *WorkerPool[T, R] { return &WorkerPool[T, R]{ workers: workers, tasks: make(chan Task[T, R], workers*2), process: process, } } func (wp *WorkerPool[T, R]) Start(ctx context.Context) { for i := 0; i < wp.workers; i++ { wp.wg.Add(1) go wp.worker(ctx) } } func (wp *WorkerPool[T, R]) worker(ctx context.Context) { defer wp.wg.Done() for { select { case task, ok := <-wp.tasks: if !ok { return } result := wp.process(task.Input) task.Result <- result close(task.Result) case <-ctx.Done(): return } } } func (wp *WorkerPool[T, R]) Submit(input T) <-chan R { resultChan := make(chan R, 1) task := Task[T, R]{ Input: input, Result: resultChan, } wp.tasks <- task return resultChan } func (wp *WorkerPool[T, R]) Shutdown() { close(wp.tasks) wp.wg.Wait() } func main() { // Integer -> String worker pool pool := NewWorkerPool(3, func(n int) string { return fmt.Sprintf("Result: %d", n*n) }) ctx := context.Background() pool.Start(ctx) // Submit tasks results := make([]<-chan string, 0) for i := 1; i <= 10; i++ { results = append(results, pool.Submit(i)) } // Collect results for i, resultChan := range results { result := <-resultChan fmt.Printf("Task %d: %s\n", i+1, result) } pool.Shutdown() } Best Practices 1. Use Constraints Wisely // Too restrictive func Sum[T int | int64](values []T) T { ... } // Better: Use constraints package import "golang.org/x/exp/constraints" func Sum[T constraints.Ordered](values []T) T { ... } 2. Prefer Type Inference // Explicit type arguments result := Map[int, string](numbers, toString) // Better: Let compiler infer result := Map(numbers, toString) 3. Keep It Simple // Overly complex func Process[T any, U any, V any](a T, fn1 func(T) U, fn2 func(U) V) V { ... } // Better: Multiple simpler functions func Step1[T, U any](a T, fn func(T) U) U { ... } func Step2[U, V any](u U, fn func(U) V) V { ... } Performance Considerations Compile Time: Generics increase compile time slightly Runtime: Zero runtime cost - monomorphization at compile time Code Size: Can increase binary size due to type specialization Type Inference: Reduces verbosity but can slow compilation Conclusion Go generics enable type-safe, reusable patterns while maintaining Go’s simplicity and performance. ...