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.

Key Takeaways:

  • Use generics for data structures and algorithms
  • Constraints provide type safety without sacrificing flexibility
  • Generic patterns reduce code duplication
  • Type inference makes code cleaner
  • Zero runtime cost compared to interface{}

Next, explore Distributed Tracing in Go to learn about OpenTelemetry implementation.


Previous: Graceful Shutdown Patterns Next: Distributed Tracing in Go Series: Go Concurrency Patterns