Go by Example

Generics

Go 1.18 added generics - type parameters let you write functions and types that work across multiple types without losing type safety.

Generics (introduced in Go 1.18) allow you to write functions and data structures that work with any type satisfying a constraint, without resorting to interface{} and type assertions. They eliminate the need to write the same algorithm three times for different element types.

A generic function uses a type parameter list in square brackets. The any constraint means the parameter accepts any type. The compiler infers the type argument from the call site - you rarely need to write it explicitly.

package main
 
import "fmt"
 
func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}
 
func Filter[T any](s []T, keep func(T) bool) []T {
    var result []T
    for _, v := range s {
        if keep(v) {
            result = append(result, v)
        }
    }
    return result
}
 
func main() {
    nums := []int{1, 2, 3, 4, 5}
    doubled := Map(nums, func(n int) int { return n * 2 })
    fmt.Println(doubled) // [2 4 6 8 10]
 
    evens := Filter(nums, func(n int) bool { return n%2 == 0 })
    fmt.Println(evens)   // [2 4]
}

Constraints restrict which types a type parameter accepts. The comparable built-in constraint means the type supports == and !=. Union constraints (~int | ~string) allow a specific set of underlying types.

package main
 
import "fmt"
 
// comparable constraint enables == comparisons
func Contains[T comparable](s []T, v T) bool {
    for _, item := range s {
        if item == v {
            return true
        }
    }
    return false
}
 
// Union constraint: accepts any type with an underlying integer type
type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}
 
func Sum[T Integer](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}
 
func main() {
    fmt.Println(Contains([]string{"a", "b", "c"}, "b")) // true
    fmt.Println(Sum([]int{1, 2, 3, 4}))                  // 10
}

Generic types let you parameterize a data structure. A typed stack or linked list avoids the interface{} cast at every call site while remaining reusable across element types.

package main
 
import (
    "errors"
    "fmt"
)
 
type Stack[T any] struct {
    items []T
}
 
func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}
 
func (s *Stack[T]) Pop() (T, error) {
    var zero T
    if len(s.items) == 0 {
        return zero, errors.New("stack is empty")
    }
    top := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return top, nil
}
 
func main() {
    var s Stack[int]
    s.Push(1)
    s.Push(2)
    v, _ := s.Pop()
    fmt.Println(v) // 2
}

In production

Generics solve the "write it three times or use interface{}" problem for container types - slices, maps, trees, queues. The constraint syntax is verbose but intentional: being explicit about what operations a type parameter must support prevents accidental misuse. Avoid generics for business logic - they add a layer of indirection that makes the code harder to read during an incident without delivering a proportional benefit. The rule of thumb: use generics for utility functions and data structures that would otherwise require reflection or code generation; use concrete types for everything in your application layer.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free