Skip to content
AgileLogics
8 min read

Advanced Generics

Prerequisites: Race conditions & sync/atomic

Level 2 used the built-in constraints any, comparable, and cmp.Ordered. This lesson covers writing your own constraints, generic types, and — just as important — when to leave generics out.

Type sets and ~

A constraint is an interface. Besides methods, it can list the types it permits, separated by |. A leading ~ means "any type whose underlying type is this":

package main

import "fmt"

type Number interface {
    ~int | ~int64 | ~float64
}

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

type Celsius float64

func main() {
    fmt.Println(Sum([]int{1, 2, 3}))
    fmt.Println(Sum([]Celsius{20.5, 21.5}))
}

Expected output:

6
42

Without the ~, Celsius would be rejected: its underlying type is float64, but it isn't float64. Interfaces that contain type elements like this can only be used as constraints, not as ordinary variable types.

A constraint can combine a type set with methods:

type StringerInt interface {
    ~int
    String() string
}

Generic types

Types can have type parameters too. A stack works for any element type:

package main

import "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, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}

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

func main() {
    var s Stack[string]
    s.Push("a")
    s.Push("b")
    v, ok := s.Pop()
    fmt.Println(v, ok, s.Len())

    var nums Stack[int]
    _, ok = nums.Pop()
    fmt.Println(ok)
}

Expected output:

b true 1
false

Note var zero T — the idiomatic way to get the zero value of an unknown type. Methods use the receiver's type parameters (Stack[T]) but cannot declare new type parameters of their own; write a top-level function instead:

// Not allowed: func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U]
func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U] {
    out := &Stack[U]{}
    for _, v := range s.items {
        out.Push(f(v))
    }
    return out
}

Multiple type parameters and inference

func Map[T, U any](xs []T, f func(T) U) []U {
    out := make([]U, 0, len(xs))
    for _, x := range xs {
        out = append(out, f(x))
    }
    return out
}

// Type arguments are inferred from the call:
// lengths := Map([]string{"go", "rust"}, func(s string) int { return len(s) })

The compiler infers type arguments from the function arguments in most cases. You only spell them out (Map[string, int](...)) when there's nothing to infer from.

When NOT to use generics

Generics remove duplication in code that is identical apart from types — containers, and algorithms over slices, maps, and channels. They are the wrong tool when:

  • Behaviour differs per type. If you'd need a type switch inside the generic function, use an interface with a method instead.
  • An interface already expresses it. func Save(w io.Writer) is simpler than func Save[W io.Writer](w W) and just as flexible.
  • There's only one type today. Write the concrete version; generalise when a second real caller appears.

A useful rule of thumb: write the concrete code first, and reach for type parameters when you find yourself writing the same function a third time.

Common mistakes

  • Forgetting ~ and then wondering why a named type like type UserID int doesn't satisfy the constraint.
  • Using any then trying to use <, +, or == — the constraint must permit the operator (cmp.Ordered, a custom type set, or comparable).
  • Returning nil for a T — nil isn't valid for every type. Return var zero T.
  • Making every helper generic "for flexibility". It makes signatures harder to read with no benefit.

Practice

Write a generic Set[T comparable] with methods Add(v T), Has(v T) bool, and Len() int, plus a constructor NewSet. Add "go" twice and "rust" once, then print Len() and Has("go").

Show solution
package main

import "fmt"

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

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{m: make(map[T]struct{})}
}

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

func main() {
    s := NewSet[string]()
    s.Add("go")
    s.Add("go")
    s.Add("rust")
    fmt.Println(s.Len(), s.Has("go"))
}

Expected output:

2 true

NewSet[string]() needs an explicit type argument because there is no function argument to infer T from.