Contents

Go Generics in Practice: Pitfalls and Best Practices

Go 1.18 finally brought generics. In our NAS project, we refactored several common modules using generics. This post documents real-world benefits, pitfalls, and best practices we learned.

1. Why Do We Need Generics?

1.1 The Pain of Duplicate Code

Before generics, we had tons of code like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func ContainsInt(slice []int, target int) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

func ContainsString(slice []string, target string) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

func ContainsInt64(slice []int64, target int64) bool {
    // Same exact logic again...
}

1.2 The Cost of interface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func Contains(slice interface{}, target interface{}) bool {
    // Requires reflection
    s := reflect.ValueOf(slice)
    for i := 0; i < s.Len(); i++ {
        if reflect.DeepEqual(s.Index(i).Interface(), target) {
            return true
        }
    }
    return false
}

// Problems:
// 1. Poor performance (reflection)
// 2. Not type-safe (no compile-time checks)
// 3. Poor readability

2. Basic Generic Refactoring

2.1 Generic Functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Go 1.18+ generic version
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

// Usage
Contains([]int{1, 2, 3}, 2)         // true
Contains([]string{"a", "b"}, "c")   // false

2.2 Generic Data Structures

Generic Set:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Set[T comparable] map[T]struct{}

func NewSet[T comparable](items ...T) Set[T] {
    s := make(Set[T])
    for _, item := range items {
        s.Add(item)
    }
    return s
}

func (s Set[T]) Add(item T) {
    s[item] = struct{}{}
}

func (s Set[T]) Contains(item T) bool {
    _, ok := s[item]
    return ok
}

func (s Set[T]) Remove(item T) {
    delete(s, item)
}

// Usage
ids := NewSet(1, 2, 3)
ids.Contains(2)  // true

Generic Result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Rust-inspired Result type
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] {
    return Result[T]{err: err}
}

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
}

3. Real-World Refactoring Cases

3.1 Cache Module Refactoring

Before:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type IntCache struct {
    data map[string]int
    mu   sync.RWMutex
}

type StringCache struct {
    data map[string]string
    mu   sync.RWMutex
}

// Had to write Get/Set/Delete for each type...

After:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type Cache[K comparable, V any] struct {
    data map[K]V
    mu   sync.RWMutex
}

func NewCache[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{
        data: make(map[K]V),
    }
}

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
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

func (c *Cache[K, V]) Delete(key K) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.data, key)
}

// Usage
userCache := NewCache[int64, *User]()
userCache.Set(1001, &User{Name: "Paul"})

3.2 Slice Utility Functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Map: like map in other languages
func Map[T, R any](slice []T, fn func(T) R) []R {
    result := make([]R, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

// Filter: filter elements
func Filter[T any](slice []T, fn func(T) bool) []T {
    result := make([]T, 0)
    for _, v := range slice {
        if fn(v) {
            result = append(result, v)
        }
    }
    return result
}

// Reduce: aggregate
func Reduce[T, R any](slice []T, initial R, fn func(R, T) R) R {
    result := initial
    for _, v := range slice {
        result = fn(result, v)
    }
    return result
}

// Usage examples
users := []User{{Age: 20}, {Age: 30}, {Age: 25}}
ages := Map(users, func(u User) int { return u.Age })         // [20, 30, 25]
adults := Filter(users, func(u User) bool { return u.Age >= 25 }) // [Age:30, Age:25]
totalAge := Reduce(users, 0, func(sum int, u User) int { return sum + u.Age }) // 75

4. Pitfalls We Hit

4.1 Methods Can’t Have Type Parameters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Wrong: methods can't have their own type parameters
type Container[T any] struct {
    data []T
}

func (c *Container[T]) Map[R any](fn func(T) R) []R {
    // Compile error: method must have no type parameters
}

// Correct: use a function instead
func Map[T, R any](c *Container[T], fn func(T) R) []R {
    result := make([]R, len(c.data))
    for i, v := range c.data {
        result[i] = fn(v)
    }
    return result
}

4.2 Can’t Use Type Parameters in Type Assertions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Wrong
func Convert[T any](v interface{}) T {
    return v.(T)  // Compile error
}

// Correct: use any as intermediate type
func Convert[T any](v interface{}) (T, bool) {
    result, ok := v.(T)
    return result, ok
}

4.3 Zero Value Issues

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func GetOrDefault[T any](ptr *T) T {
    if ptr == nil {
        var zero T
        return zero  // Returns type's zero value
    }
    return *ptr
}

// But what if you need an explicit default?
func GetOrDefault[T any](ptr *T, defaultVal T) T {
    if ptr == nil {
        return defaultVal
    }
    return *ptr
}

4.4 Constraint Design

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Custom constraint
type Number interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

// Use golang.org/x/exp/constraints
import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

5. Performance Comparison

5.1 Generics vs Reflection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Benchmark test
func BenchmarkGeneric(b *testing.B) {
    slice := make([]int, 1000)
    for i := range slice {
        slice[i] = i
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        Contains(slice, 500)
    }
}

func BenchmarkReflect(b *testing.B) {
    slice := make([]int, 1000)
    for i := range slice {
        slice[i] = i
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ContainsReflect(slice, 500)
    }
}

Results:

1
2
BenchmarkGeneric-8     5000000    250 ns/op     0 B/op   0 allocs/op
BenchmarkReflect-8      500000   3200 ns/op   100 B/op   5 allocs/op

Generics are 12x faster with zero allocations!

5.2 Generics vs Hand-Written

1
2
3
4
5
6
7
func BenchmarkGenericContains(b *testing.B) {
    // Generic version
}

func BenchmarkHandwrittenContains(b *testing.B) {
    // func ContainsInt([]int, int) bool version
}

Results:

1
2
BenchmarkGeneric-8         5000000    250 ns/op
BenchmarkHandwritten-8     5000000    248 ns/op

Nearly identical! Go compiler generates specialized code for each type (monomorphization).

6. Best Practices

6.1 When to Use Generics

Good fit:

  • Container types (List, Set, Map, Stack, Queue)
  • Common algorithms (sort, search, filter)
  • Utility functions (Contains, Map, Reduce)

Not a good fit:

  • Business logic code (don’t over-abstract)
  • Only one or two types (not worth generalizing)
  • Need runtime type info (generics are compile-time)

6.2 Constraint Selection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Prefer built-in constraints
any         // Any type
comparable  // Types supporting == !=

// Use x/exp/constraints
constraints.Ordered    // Supports < > <= >=
constraints.Integer    // Integers
constraints.Float      // Floats
constraints.Signed     // Signed numbers
constraints.Unsigned   // Unsigned numbers

6.3 Naming Conventions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Type parameter naming
T        // Single type
K, V     // Key-value pair
T, R     // Input-output
E        // Element
S        // Slice

// Constraint naming
type Stringer interface {
    String() string
}

type Comparable[T any] interface {
    Compare(T) int
}

7. Project Benefits

7.1 Code Reduction

ModuleBeforeAfterReduction
Cache450 lines120 lines-73%
Slice utils280 lines80 lines-71%
Data structures600 lines200 lines-67%

7.2 Maintenance Cost

  • Bug fixes: Used to fix N versions, now fix once
  • New types: Used to copy code, now just use
  • Type safety: Errors caught at compile time

8. Summary

PointRecommendation
Use casesContainers, utilities, algorithms
PerformanceEqual to hand-written, far better than reflection
ConstraintsPrefer any/comparable/constraints
MethodsMethods can’t have type params, use functions
Zero valuesUse var zero T to get them

Key takeaway: Generics aren’t a silver bullet, but in the right scenarios, they significantly reduce duplicate code while maintaining type safety and performance.