Skip to main content

Generics in Go

Go 1.18 introduced generics (type parameters), one of the most requested features in Go’s history. Generics allow you to write functions and types that work with any data type while maintaining full type safety at compile time.

Why Generics?

Before generics, you had two options for writing reusable code:
  1. Use interface{}: Lose type safety and require type assertions
  2. Code generation: Maintain multiple copies of similar code
// Before generics: Using interface{}
func MinInterface(a, b interface{}) interface{} {
    // No type safety, runtime panics possible
    switch a := a.(type) {
    case int:
        if a < b.(int) {
            return a
        }
        return b
    case float64:
        if a < b.(float64) {
            return a
        }
        return b
    default:
        panic("unsupported type")
    }
}

// With generics: Type-safe and clean
func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

Type Parameters

Type parameters are declared in square brackets before the function parameters.

Basic Syntax

func FunctionName[T TypeConstraint](params) returnType {
    // function body
}

Simple Generic Function

package main

import "fmt"

// Print any slice
func PrintSlice[T any](s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Println()
}

func main() {
    PrintSlice([]int{1, 2, 3})           // 1 2 3
    PrintSlice([]string{"a", "b", "c"})  // a b c
    PrintSlice([]float64{1.1, 2.2, 3.3}) // 1.1 2.2 3.3
}

Multiple Type Parameters

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
}

// Usage
nums := []int{1, 2, 3, 4}
strings := Map(nums, func(n int) string {
    return fmt.Sprintf("num:%d", n)
})
// strings = ["num:1", "num:2", "num:3", "num:4"]

Type Constraints

Constraints define what operations can be performed on type parameters.

The any Constraint

any is an alias for interface{} - allows any type.
func Identity[T any](v T) T {
    return v
}

The comparable Constraint

comparable allows types that support == and != operators.
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

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

The constraints Package

The golang.org/x/exp/constraints package provides common constraints:
import "golang.org/x/exp/constraints"

// Ordered: types that support <, <=, >, >=
func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Integer: all integer types (int, int8, int16, int32, int64, uint, etc.)
func Sum[T constraints.Integer](nums []T) T {
    var sum T
    for _, n := range nums {
        sum += n
    }
    return sum
}

// Float: float32 and float64
func Average[T constraints.Float](nums []T) T {
    if len(nums) == 0 {
        return 0
    }
    var sum T
    for _, n := range nums {
        sum += n
    }
    return sum / T(len(nums))
}

Custom Constraints

Define your own constraints using interface syntax:
// Union constraint: allows specific types
type Number interface {
    int | int32 | int64 | float32 | float64
}

func Double[T Number](n T) T {
    return n * 2
}

// Constraint with methods
type Stringer interface {
    String() string
}

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

// Combining constraints
type OrderedStringer interface {
    constraints.Ordered
    fmt.Stringer
}

Underlying Type Constraint (~)

The ~ operator matches types with the same underlying type:
type MyInt int

// Without ~: only accepts int
type IntOnly interface {
    int
}

// With ~: accepts int and any type with underlying type int (like MyInt)
type IntLike interface {
    ~int
}

func Triple[T IntLike](n T) T {
    return n * 3
}

func main() {
    var x MyInt = 5
    fmt.Println(Triple(x)) // Works! Output: 15
}

Generic Types

You can also define generic structs, interfaces, and type aliases.

Generic Structs

// 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
}

func (s *Stack[T]) Peek() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        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, _ := intStack.Pop() // val = 2

stringStack := &Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")

Generic Linked List

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

type LinkedList[T any] struct {
    Head *Node[T]
    Tail *Node[T]
    len  int
}

func (l *LinkedList[T]) Append(value T) {
    node := &Node[T]{Value: value}
    if l.Head == nil {
        l.Head = node
        l.Tail = node
    } else {
        l.Tail.Next = node
        l.Tail = node
    }
    l.len++
}

func (l *LinkedList[T]) ToSlice() []T {
    result := make([]T, 0, l.len)
    for current := l.Head; current != nil; current = current.Next {
        result = append(result, current.Value)
    }
    return result
}

Generic Map/Dictionary

type Pair[K comparable, V any] struct {
    Key   K
    Value V
}

type OrderedMap[K comparable, V any] struct {
    pairs []Pair[K, V]
    index map[K]int
}

func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] {
    return &OrderedMap[K, V]{
        pairs: make([]Pair[K, V], 0),
        index: make(map[K]int),
    }
}

func (m *OrderedMap[K, V]) Set(key K, value V) {
    if idx, exists := m.index[key]; exists {
        m.pairs[idx].Value = value
        return
    }
    m.index[key] = len(m.pairs)
    m.pairs = append(m.pairs, Pair[K, V]{Key: key, Value: value})
}

func (m *OrderedMap[K, V]) Get(key K) (V, bool) {
    if idx, exists := m.index[key]; exists {
        return m.pairs[idx].Value, true
    }
    var zero V
    return zero, false
}

func (m *OrderedMap[K, V]) Keys() []K {
    keys := make([]K, len(m.pairs))
    for i, p := range m.pairs {
        keys[i] = p.Key
    }
    return keys
}

Common Generic Patterns

Filter, Map, Reduce

package collections

// Filter returns elements that satisfy the predicate
func Filter[T any](slice []T, predicate func(T) bool) []T {
    result := make([]T, 0)
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

// Map transforms each element
func Map[T, U any](slice []T, transform func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = transform(v)
    }
    return result
}

// Reduce combines all elements into a single value
func Reduce[T, U any](slice []T, initial U, reducer func(U, T) U) U {
    result := initial
    for _, v := range slice {
        result = reducer(result, v)
    }
    return result
}

// Usage
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

evens := Filter(nums, func(n int) bool { return n%2 == 0 })
// [2, 4, 6, 8, 10]

doubled := Map(nums, func(n int) int { return n * 2 })
// [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
// 55

Result Type (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]) IsOk() bool {
    return r.err == nil
}

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

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
}

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

// Usage
func divide(a, b int) Result[int] {
    if b == 0 {
        return Err[int](errors.New("division by zero"))
    }
    return Ok(a / b)
}

result := divide(10, 2)
if result.IsOk() {
    fmt.Println(result.Unwrap()) // 5
}

Optional Type

type Optional[T any] struct {
    value   T
    present bool
}

func Some[T any](value T) Optional[T] {
    return Optional[T]{value: value, present: true}
}

func None[T any]() Optional[T] {
    return Optional[T]{present: false}
}

func (o Optional[T]) IsSome() bool {
    return o.present
}

func (o Optional[T]) IsNone() bool {
    return !o.present
}

func (o Optional[T]) Get() (T, bool) {
    return o.value, o.present
}

func (o Optional[T]) OrElse(defaultValue T) T {
    if o.present {
        return o.value
    }
    return defaultValue
}

// Usage
func findUser(id int) Optional[User] {
    user, found := db.GetUser(id)
    if !found {
        return None[User]()
    }
    return Some(user)
}

Type Inference

Go can often infer type parameters from function arguments:
func First[T any](slice []T) (T, bool) {
    if len(slice) == 0 {
        var zero T
        return zero, false
    }
    return slice[0], true
}

// Type inference - no need to specify [int]
val, ok := First([]int{1, 2, 3})

// Explicit type parameter (sometimes needed)
val, ok := First[int]([]int{1, 2, 3})

Best Practices

When to Use Generics

Good use cases:
  • Collection types (stacks, queues, trees)
  • Utility functions (filter, map, reduce)
  • Type-safe containers
  • Algorithms that work on multiple types
Avoid generics when:
  • A single concrete type works fine
  • Interfaces provide sufficient abstraction
  • It makes the code harder to read

Keep Constraints Simple

// ❌ Overly complex constraint
type WeirdConstraint interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64
}

// ✅ Use standard constraints
import "golang.org/x/exp/constraints"
type Number constraints.Integer | constraints.Float

Zero Values in Generics

func GetOrDefault[T any](slice []T, index int, defaultVal T) T {
    if index < 0 || index >= len(slice) {
        return defaultVal
    }
    return slice[index]
}

// Creating a zero value
func Zero[T any]() T {
    var zero T
    return zero
}

Interview Questions

Generics allow writing functions and types that work with any data type while maintaining type safety. They were added in Go 1.18 to:
  • Reduce code duplication
  • Eliminate the need for type assertions with interface{}
  • Enable type-safe container types
  • Improve code reusability without sacrificing performance
  • any is an alias for interface{} and allows any type
  • comparable restricts to types that support == and != operators
  • Use comparable when you need to compare values (e.g., map keys, finding elements)
The ~ operator matches types with the same underlying type. For example, ~int matches int and any custom type like type MyInt int. Without ~, only the exact type matches.
No, Go does not support type parameters on methods. You can only have type parameters on the type itself or on standalone functions. This is a deliberate design decision to keep the language simple.

Summary

ConceptDescription
Type ParametersDeclared in [T Constraint] before function params
anyAllows any type (alias for interface{})
comparableTypes supporting == and !=
constraints.OrderedTypes supporting <, >, <=, >=
~TMatches types with underlying type T
Generic StructsStructs with type parameters
Type InferenceGo often infers type parameters automatically