Skip to content

Generics (Go 1.18+) Advanced

Introduction

Go 1.18 introduced generics (type parameters), the most significant language change since Go 1.0. Generics allow you to write functions and types that work with any type satisfying a constraint, eliminating the need for interface{} casts, code duplication, or code generation for type-safe abstractions.

Before generics, Go developers faced a trade-off: type safety (writing separate IntStack, StringStack) vs. reusability (interface{} with runtime type assertions). Generics resolve this tension — you get both.

Syntax & Usage

Type Parameters and Constraints

// Basic generic function
// T is a type parameter, 'any' is the constraint
func Print[T any](value T) {
    fmt.Println(value)
}

// Multiple type parameters
func Map[T any, 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
}

// Calling generic functions
Print[int](42)        // Explicit type argument
Print("hello")        // Type inference — compiler deduces T = string
doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })

Built-in Constraints

// 'any' — alias for interface{}, allows any type
func Identity[T any](v T) T { return v }

// 'comparable' — types that support == and !=
// Required for map keys, equality checks
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

Custom Constraints

// Constraint interface — defines what operations T must support
type Number interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64
}

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

// The ~ (tilde) means "underlying type"
type Celsius float64
type Fahrenheit float64

// Without ~float64, Celsius and Fahrenheit would NOT satisfy the constraint
// With ~float64, any type whose underlying type is float64 satisfies it
fmt.Println(Sum([]Celsius{20.5, 30.1}))  // Works because of ~float64

Constraint Interfaces with Methods

type Stringer interface {
    ~int | ~string
    String() string
}

// Combine type terms and method requirements
type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64 | ~string
}

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

The constraints Package (golang.org/x/exp/constraints)

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

// Pre-defined constraints:
// constraints.Signed    — all signed integer types
// constraints.Unsigned  — all unsigned integer types
// constraints.Integer   — Signed | Unsigned
// constraints.Float     — ~float32 | ~float64
// constraints.Complex   — ~complex64 | ~complex128
// constraints.Ordered   — Integer | Float | ~string

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

Generic Types

// Generic Stack
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
    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) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

func (s *Stack[T]) Len() int { return len(s.items) }

// Usage
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
val, ok := intStack.Pop() // val=2, ok=true

Generic Set

type Set[T comparable] struct {
    items map[T]struct{}
}

func NewSet[T comparable](values ...T) *Set[T] {
    s := &Set[T]{items: make(map[T]struct{})}
    for _, v := range values {
        s.Add(v)
    }
    return s
}

func (s *Set[T]) Add(v T)            { s.items[v] = struct{}{} }
func (s *Set[T]) Remove(v T)         { delete(s.items, v) }
func (s *Set[T]) Contains(v T) bool  { _, ok := s.items[v]; return ok }
func (s *Set[T]) Len() int           { return len(s.items) }

func (s *Set[T]) Union(other *Set[T]) *Set[T] {
    result := NewSet[T]()
    for k := range s.items {
        result.Add(k)
    }
    for k := range other.items {
        result.Add(k)
    }
    return result
}

func (s *Set[T]) Intersection(other *Set[T]) *Set[T] {
    result := NewSet[T]()
    for k := range s.items {
        if other.Contains(k) {
            result.Add(k)
        }
    }
    return result
}

Result Type (Rust-inspired)

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, error) {
    return r.value, r.err
}

func (r Result[T]) IsOk() bool { return r.err == nil }

func (r Result[T]) Map(fn func(T) T) Result[T] {
    if r.err != nil {
        return r
    }
    return Ok(fn(r.value))
}

func (r Result[T]) OrElse(defaultVal T) T {
    if r.err != nil {
        return defaultVal
    }
    return r.value
}

// Usage
result := Ok(42).Map(func(v int) int { return v * 2 })
value := result.OrElse(0) // 84

Functional Utilities

func Filter[T any](slice []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
    acc := initial
    for _, v := range slice {
        acc = fn(acc, v)
    }
    return acc
}

func GroupBy[T any, K comparable](slice []T, keyFn func(T) K) map[K][]T {
    result := make(map[K][]T)
    for _, v := range slice {
        key := keyFn(v)
        result[key] = append(result[key], v)
    }
    return result
}

// Usage
evens := Filter([]int{1, 2, 3, 4, 5}, func(n int) bool { return n%2 == 0 })
sum := Reduce([]int{1, 2, 3, 4}, 0, func(acc, n int) int { return acc + n })

Generic Repository Pattern

type Entity interface {
    comparable
    GetID() string
}

type Repository[T Entity] struct {
    store map[string]T
    mu    sync.RWMutex
}

func NewRepository[T Entity]() *Repository[T] {
    return &Repository[T]{store: make(map[string]T)}
}

func (r *Repository[T]) Create(entity T) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    id := entity.GetID()
    if _, exists := r.store[id]; exists {
        return fmt.Errorf("entity %s already exists", id)
    }
    r.store[id] = entity
    return nil
}

func (r *Repository[T]) FindByID(id string) (T, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    entity, ok := r.store[id]
    if !ok {
        var zero T
        return zero, fmt.Errorf("entity %s not found", id)
    }
    return entity, nil
}

func (r *Repository[T]) FindAll(predicate func(T) bool) []T {
    r.mu.RLock()
    defer r.mu.RUnlock()
    var results []T
    for _, entity := range r.store {
        if predicate(entity) {
            results = append(results, entity)
        }
    }
    return results
}

func (r *Repository[T]) Delete(id string) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    if _, exists := r.store[id]; !exists {
        return fmt.Errorf("entity %s not found", id)
    }
    delete(r.store, id)
    return nil
}

// Usage
type User struct {
    ID   string
    Name string
    Age  int
}

func (u User) GetID() string { return u.ID }

repo := NewRepository[User]()
repo.Create(User{ID: "1", Name: "Alice", Age: 30})
user, _ := repo.FindByID("1")
adults := repo.FindAll(func(u User) bool { return u.Age >= 18 })

The slices and maps Packages (stdlib since Go 1.21)

import (
    "slices"
    "maps"
)

// slices package
nums := []int{3, 1, 4, 1, 5, 9}
slices.Sort(nums)                          // [1, 1, 3, 4, 5, 9]
idx, found := slices.BinarySearch(nums, 4) // idx=3, found=true
slices.Reverse(nums)
slices.Contains(nums, 5)                   // true
compact := slices.Compact([]int{1, 1, 2, 2, 3}) // [1, 2, 3]

// maps package
m := map[string]int{"a": 1, "b": 2, "c": 3}
keys := maps.Keys(m)       // unordered slice of keys
values := maps.Values(m)   // unordered slice of values
maps.DeleteFunc(m, func(k string, v int) bool { return v < 2 })
clone := maps.Clone(m)
maps.Equal(m, clone)       // true

Quick Reference

Concept Syntax Example
Type parameter func F[T constraint]() func Print[T any](v T)
Multiple params func F[T, U constraint]() func Map[T any, U any](...)
Any type any [T any]
Equality support comparable [T comparable]
Custom constraint type C interface { ... } type Number interface { ~int \| ~float64 }
Underlying type ~T in constraint ~int matches type MyInt int
Union types T1 \| T2 in constraint ~int \| ~string
Generic type type S[T constraint] struct{} type Stack[T any] struct{}
Zero value var zero T Return default for type param
Type inference Omit [T] at call site Print("hello") vs Print[string]("hello")

Best Practices

  1. Start without generics — write concrete code first, then generalize only when you see clear duplication across multiple types.

  2. Use the narrowest constraint possible — if you only need equality, use comparable, not any.

  3. Prefer stdlib packages — use slices, maps, and cmp before writing your own generic utilities.

  4. Name constraints meaningfullyNumber, Ordered, Entity are better than T1, C.

  5. Use type inference — let the compiler infer type arguments when it can: Max(1, 2) over Max[int](1, 2).

  6. Document constraints — explain why a constraint is needed, especially for custom ones.

Common Pitfalls

No Method-Level Type Parameters

Go does not allow type parameters on methods — only on functions and type definitions.

// ❌ This does NOT compile
func (s *Stack[T]) Map[U any](fn func(T) U) *Stack[U] { ... }

// ✅ Use a top-level function instead
func MapStack[T any, U any](s *Stack[T], fn func(T) U) *Stack[U] {
    result := &Stack[U]{}
    for _, item := range s.items {
        result.Push(fn(item))
    }
    return result
}

Zero Value Trap

Generic code cannot use typed literals like 0 or "". Use var zero T for the zero value.

func First[T any](slice []T) T {
    var zero T
    if len(slice) == 0 {
        return zero // Returns zero value of whatever T is
    }
    return slice[0]
}

No Specialization

You cannot provide a specialized implementation of a generic function for a particular type. Every instantiation uses the same code.

// ❌ Not possible in Go
func Process[T any](v T) { /* general */ }
func Process[string](v string) { /* specialized for string */ }

Comparable Gotcha with Interfaces

Interface types satisfy comparable at compile time, but may panic at runtime if the underlying value is not comparable (e.g., a slice inside an interface).

func Equal[T comparable](a, b T) bool { return a == b }

var x, y interface{} = []int{1}, []int{1}
Equal(x, y) // Compiles, but PANICS at runtime

Performance Considerations

  • Monomorphization vs. GCShape stenciling: Go does NOT fully monomorphize like C++ templates or Rust generics. Instead, it uses GCShape stenciling — types with the same GC shape (pointer vs. non-pointer) share a single compiled function. This reduces binary size but can add indirection overhead for pointer-shaped types.

  • Pointer-shaped types share one implementation: Stack[*User] and Stack[*Order] use the same generated code with dictionary-based dispatch. Value types like Stack[int] and Stack[float64] may get separate implementations.

  • Interface{} boxing may still occur in some generic code paths, though the compiler is improving. Profile before assuming generics are zero-cost.

  • Compilation time: Generics can increase compile time, especially with complex constraint combinations. Generally negligible for most projects.

  • Benchmark the hot path: For performance-critical code, benchmark generic vs. concrete implementations. In most cases the difference is under 5%, but in tight loops with pointer-shaped types, the dictionary lookup overhead can matter.

// Benchmark example
func BenchmarkGenericSum(b *testing.B) {
    data := make([]int, 1000)
    for i := range data { data[i] = i }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        Sum(data) // generic
    }
}

func BenchmarkConcreteSum(b *testing.B) {
    data := make([]int, 1000)
    for i := range data { data[i] = i }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sumInts(data) // concrete
    }
}

Interview Tips

Interview Tip

When asked about generics, frame them in the historical context: before 1.18, Go relied on interface{} (no type safety), code generation (go generate), or copy-paste. Generics solve the type-safe reusability problem. Mention the design philosophy — Go generics are deliberately simpler than C++ templates or Rust traits, favoring readability over power.

Interview Tip

A common question is "When would you NOT use generics?" Good answers: when there's only one concrete type, when the logic differs per type (use interfaces), when any/interface{} already suffices (no type safety gained), or when code generation is already established in the project.

Interview Tip

If asked about performance, explain GCShape stenciling: Go doesn't create a new function for every type instantiation like C++. Types with the same "shape" (e.g., all pointer types) share one implementation. This trades some runtime performance for smaller binary size — a deliberate trade-off reflecting Go's priorities.

Key Takeaways

  • Generics enable type-safe reusable code without sacrificing performance or readability.
  • Constraints define what operations a type parameter supports — use the narrowest constraint that works.
  • The tilde operator (~int) matches types whose underlying type matches, critical for user-defined types.
  • Go generics are intentionally limited — no method type parameters, no specialization, no variadic type parameters.
  • Prefer concrete code until you see clear duplication, then generalize with generics.
  • The slices, maps, and cmp stdlib packages provide battle-tested generic utilities — use them.