Generics in Go have fundamentally changed how I design systems. Since their introduction in Go 1.18, I’ve discovered patterns that solve concrete problems while preserving Go’s signature simplicity. Here are practical techniques I regularly use in production, extending far beyond basic type parameters.
Type-Safe Collections
Creating reusable collections without interface{}
has transformed my data handling. Consider this thread-safe generic stack:
type Stack[T any] struct {
items []T
mu sync.Mutex
}
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
}
// Usage
var intStack Stack[int]
intStack.Push(42)
val, ok := intStack.Pop() // val=42, ok=true
This pattern eliminates type assertions while maintaining compile-time safety. I’ve extended it to queues, trees, and LRU caches with consistent type constraints.
Constraint-Based Algorithms
Handling multiple numeric types in algorithms became cleaner. Here’s a statistics package I built:
type Numeric interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Average[T Numeric](values []T) T {
var sum T
for _, v := range values {
sum += v
}
return sum / T(len(values))
}
// Handles ints, floats, or custom types
temps := []float32{22.5, 23.7, 19.8}
fmt.Println(Average(temps)) // 22.0
The Numeric
constraint ensures we only accept valid types. I’ve applied this to financial calculations where supporting multiple precision levels is crucial.
Functional Helpers
Type-preserving map/filter operations prevent reflection overhead:
func Filter[T any](slice []T, test func(T) bool) []T {
result := make([]T, 0, len(slice))
for _, item := range slice {
if test(item) {
result = append(result, item)
}
}
return result
}
// Usage
users := []User{{Name: "Alice", Active: true}, {Name: "Bob", Active: false}}
activeUsers := Filter(users, func(u User) bool {
return u.Active
})
In data pipelines, this maintains type information through transformations. I combine it with Map
and Reduce
for processing large datasets.
Generic Middleware
HTTP handlers gain type safety with context-specific dependencies:
type ContextKey string
func WithAuth[T any](next func(T, http.ResponseWriter, *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := authenticate(r)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Pass authenticated user to handler
var context T
context.User = user
next(context, w, r)
}
}
// Handler expects custom context
func ProfileHandler(ctx UserContext, w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome %s", ctx.User.Name)
}
router.Handle("/profile", WithAuth(ProfileHandler))
This pattern enforces required context fields while keeping middleware reusable. I’ve used it to propagate tracing, localization, and database connections.
Type-Safe Options Pattern
Configuration builders catch errors at compile time:
type ServerConfig struct {
Port int
Timeout time.Duration
}
type Option func(*ServerConfig)
func WithPort(port int) Option {
return func(c *ServerConfig) {
c.Port = port
}
}
func NewServer(opts ...Option) *Server {
cfg := &ServerConfig{Port: 8080, Timeout: 30*time.Second} // Defaults
for _, opt := range opts {
opt(cfg)
}
// Initialize server with cfg
}
// Usage - invalid types caught by compiler
server := NewServer(
WithPort("8080"), // Compile error: string vs int
)
This approach has eliminated entire categories of configuration bugs in my projects.
Reusable Testing Utilities
Generic test harnesses work across types:
func TestSort[T any](t *testing.T, impl func([]T), cases []struct{
Input []T
Expected []T
}) {
t.Helper()
for _, tc := range cases {
t.Run(fmt.Sprintf("%v", tc.Input), func(t *testing.T) {
data := make([]T, len(tc.Input))
copy(data, tc.Input)
impl(data)
if !reflect.DeepEqual(data, tc.Expected) {
t.Errorf("Got %v, want %v", data, tc.Expected)
}
})
}
}
// Test integer sorter
TestSort(t, IntSorter, []struct{
Input []int
Expected []int
}{
{[]int{3,1,2}, []int{1,2,3}},
{[]int{-1,0,1}, []int{-1,0,1}},
})
I maintain a library of such utilities for database mocks, JSON serialization checks, and concurrency testing.
Protocol Buffers/JSON Adapters
Unified serialization for multiple formats:
type Marshaller[T any] interface {
Marshal(T) ([]byte, error)
}
type JSONMarshaller[T any] struct{}
func (jm *JSONMarshaller[T]) Marshal(data T) ([]byte, error) {
return json.Marshal(data)
}
type ProtoMarshaller[T proto.Message] struct{}
func (pm *ProtoMarshaller[T]) Marshal(msg T) ([]byte, error) {
return proto.Marshal(msg)
}
func SendData[T any](w http.ResponseWriter, data T, m Marshaller[T]) {
bytes, err := m.Marshal(data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(bytes)
}
// Usage
SendData(w, userData, &JSONMarshaller[User]{})
This abstraction handles content negotiation in my APIs while keeping error handling consistent.
Cache Implementations
Type-aware caching with concurrency control:
type Cache[K comparable, V any] struct {
data map[K]V
mu sync.RWMutex
ttl time.Duration
}
func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{
data: make(map[K]V),
ttl: ttl,
}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
time.AfterFunc(c.ttl, func() { c.Delete(key) })
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// Usage
userCache := NewCache[string, User](10*time.Minute)
userCache.Set("user-123", User{Name: "Alice"})
I use this for session storage, API response caching, and computationally expensive results. The type constraints prevent accidental misuse of cached values.
These patterns represent real-world solutions I’ve refined through trial and error. Generics haven’t just added flexibility—they’ve made my Go code safer, more maintainable, and surprisingly more expressive within Go’s pragmatic constraints. Each pattern addresses specific pain points I encountered in large-scale systems, from eliminating boilerplate to enforcing type contracts that prevent runtime errors.