Go Generics: Type Parameters in Go

Go 1.18 introduced type parameters, commonly known as generics, ending years of debate about whether Go needed them. Before generics, developers faced an uncomfortable choice: write duplicate code...

Key Insights

  • Go generics eliminate type-specific code duplication while maintaining type safety, replacing the need for code generation or unsafe interface{} casting in collection and utility functions.
  • Type constraints using interface definitions let you specify exactly what operations your generic code can perform, from simple comparability checks to custom numeric operations with the ~ approximation operator.
  • Generic code works best for data structures and algorithms that operate identically across types—avoid generics when traditional interfaces provide clearer abstractions or when type-specific behavior is needed.

Introduction to Go Generics

Go 1.18 introduced type parameters, commonly known as generics, ending years of debate about whether Go needed them. Before generics, developers faced an uncomfortable choice: write duplicate code for each type, use interface{} and sacrifice type safety, or generate code with tools. None of these solutions were satisfying.

Consider this pre-generics scenario:

func MinInt(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func MinFloat64(a, b float64) float64 {
    if a < b {
        return a
    }
    return b
}

func MinString(a, b string) string {
    if a < b {
        return a
    }
    return b
}

This pattern repeated across countless codebases. With generics, this collapses to:

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

// Use with any ordered type
result1 := Min(5, 10)           // int
result2 := Min(3.14, 2.71)      // float64
result3 := Min("apple", "banana") // string

The compiler infers the type parameter automatically, and you get compile-time type safety without duplication.

Basic Type Parameter Syntax

Type parameters appear in square brackets between the function name and regular parameters. The basic form is [T constraint], where T is your type parameter name and constraint defines what types are allowed.

The simplest constraint is any, which accepts any type:

func First[T any](slice []T) (T, bool) {
    if len(slice) == 0 {
        var zero T
        return zero, false
    }
    return slice[0], true
}

For types that can be compared with == and !=, use the comparable constraint:

func Contains[T comparable](slice []T, value T) bool {
    for _, item := range slice {
        if item == value {
            return true
        }
    }
    return false
}

Type parameters work with custom types too. Here’s a 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) {
    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
intStack := Stack[int]{}
intStack.Push(42)
intStack.Push(100)

Type Constraints and Interfaces

Constraints are interfaces that define what operations your generic code can perform. The cmp.Ordered constraint from the standard library allows <, >, <=, and >= operations:

import "cmp"

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

Create custom constraints using interface definitions with type unions:

type Numeric interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

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

The ~ operator (approximation) matches types with the same underlying type. This is crucial for custom type definitions:

type Celsius float64
type Fahrenheit float64

type Float interface {
    ~float32 | ~float64
}

func Average[T Float](values []T) T {
    if len(values) == 0 {
        return 0
    }
    var sum T
    for _, v := range values {
        sum += v
    }
    return sum / T(len(values))
}

// Works with both custom types and built-in floats
temps := []Celsius{20.5, 21.0, 19.8}
avg := Average(temps) // Returns Celsius

Constraints can also require methods:

type Stringer interface {
    String() string
}

func PrintAll[T Stringer](items []T) {
    for _, item := range items {
        fmt.Println(item.String())
    }
}

Generic Data Structures

Generics excel at implementing type-safe data structures. Here’s a generic linked list node:

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

type LinkedList[T any] struct {
    head *Node[T]
}

func (l *LinkedList[T]) Append(value T) {
    node := &Node[T]{Value: value}
    if l.head == nil {
        l.head = node
        return
    }
    current := l.head
    for current.Next != nil {
        current = current.Next
    }
    current.Next = node
}

func (l *LinkedList[T]) ToSlice() []T {
    var result []T
    current := l.head
    for current != nil {
        result = append(result, current.Value)
        current = current.Next
    }
    return result
}

Generic higher-order functions become straightforward:

func Map[T, 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
}

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
}

// Usage
numbers := []int{1, 2, 3, 4, 5}
doubled := Map(numbers, func(n int) int { return n * 2 })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })

A generic Result type provides type-safe error handling:

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
}

Type Inference and Instantiation

Go’s compiler infers type arguments from function arguments in most cases:

func Print[T any](value T) {
    fmt.Println(value)
}

Print(42)        // T inferred as int
Print("hello")   // T inferred as string
Print(3.14)      // T inferred as float64

When inference fails or you want explicit types, use explicit instantiation:

func Zero[T any]() T {
    var zero T
    return zero
}

// Must specify type explicitly - no arguments to infer from
z := Zero[int]()
s := Zero[string]()

Type inference works with multiple type parameters:

func Pair[T, U any](first T, second U) (T, U) {
    return first, second
}

// Both types inferred
p := Pair(42, "answer") // T=int, U=string

Sometimes you need partial specification when inference is ambiguous:

func Convert[T, U any](value T, converter func(T) U) U {
    return converter(value)
}

// Explicit type needed for U
result := Convert[int, string](42, strconv.Itoa)

Limitations and Best Practices

Go generics have intentional limitations. You cannot add type parameters to methods, only to the receiver type itself:

// This doesn't work
type Processor struct{}
func (p Processor) Process[T any](value T) T { return value }

// Instead, parameterize the type
type Processor[T any] struct{}
func (p Processor[T]) Process(value T) T { return value }

Don’t use generics when traditional interfaces are clearer. This is over-engineered:

// Overly generic
func Write[T io.Writer](w T, data []byte) error {
    _, err := w.Write(data)
    return err
}

// Just use the interface
func Write(w io.Writer, data []byte) error {
    _, err := w.Write(data)
    return err
}

Use generics for algorithms and data structures that work identically across types. Avoid them when different types need different behavior—that’s what interfaces and methods are for.

Real-World Use Cases

A generic cache implementation demonstrates practical value:

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]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

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
}

// Type-safe caches for different use cases
userCache := NewCache[int, User]()
sessionCache := NewCache[string, Session]()

Generic validation pipelines provide type-safe transformations:

type Validator[T any] func(T) error

func Validate[T any](value T, validators ...Validator[T]) error {
    for _, validator := range validators {
        if err := validator(value); err != nil {
            return err
        }
    }
    return nil
}

// Usage with user input
func ValidateAge(age int) error {
    if age < 0 || age > 150 {
        return errors.New("invalid age")
    }
    return nil
}

err := Validate(25, ValidateAge)

Go generics solve real problems without sacrificing the language’s simplicity. Use them where they eliminate duplication and improve type safety, but reach for interfaces when you need runtime polymorphism or type-specific behavior. The key is recognizing which tool fits the problem at hand.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.