51. Generic types

📖 Reading · 10 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Structs take type parameters too. That's how you write a container — a stack, a queue, a cache, a result wrapper — once, and use it with any element type, with full compile-time checking.

A generic stack

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() {
    ints := &Stack[int]{}
    ints.Push(1)
    ints.Push(2)
    ints.Push(3)

    v, ok := ints.Pop()
    fmt.Println(v, ok, "remaining:", ints.Len())

    words := &Stack[string]{}
    words.Push("go")
    words.Push("generics")
    w, _ := words.Pop()
    fmt.Println(w)

    empty := &Stack[float64]{}
    f, ok := empty.Pop()
    fmt.Println(f, ok)
}

Three things to notice:

  1. Stack[T any] puts the parameter on the type, so every method gets it.
  2. Methods repeat the parameter in the receiver: func (s *Stack[T]). It's not a second declaration — it's binding the name so the body can use it.
  3. Using it requires a concrete type: Stack[int], Stack[string]. A bare Stack is not a type.

Stack[int] and Stack[string] are entirely separate types. Pushing a string onto Stack[int] doesn't compile — the whole point, compared to the old []any version where it would compile and blow up later.

Methods cannot add their own type parameters. func (s *Stack[T]) MapTo[U any](...) is illegal in Go. When you need a second type, write a generic function that takes the container.

A generic pair

package main

import "fmt"

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

func (p Pair[K, V]) String() string {
    return fmt.Sprintf("%v=%v", p.Key, p.Val)
}

func NewPair[K comparable, V any](k K, v V) Pair[K, V] {
    return Pair[K, V]{Key: k, Val: v}
}

func main() {
    a := NewPair("age", 36)
    b := NewPair(1, []string{"go", "rust"})

    fmt.Println(a)
    fmt.Println(b)
    fmt.Println(a.Key, a.Val+1)
}

Constructors are where inference pays off: NewPair("age", 36) figures out Pair[string, int] from the arguments, so you never type the parameters. A New... function alongside a generic type is a very common pairing.

A typed cache

Something you'd actually ship:

package main

import (
    "fmt"
    "sort"
    "sync"
)

type Cache[K comparable, V any] struct {
    mu    sync.RWMutex
    items map[K]V
}

func NewCache[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{items: make(map[K]V)}
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache[K, V]) Len() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.items)
}

type User struct {
    Name string
    Age  int
}

func main() {
    users := NewCache[int, User]()
    users.Set(1, User{Name: "Ada", Age: 36})
    users.Set(2, User{Name: "Grace", Age: 45})

    if u, ok := users.Get(1); ok {
        fmt.Printf("%s is %d\n", u.Name, u.Age)
    }
    if _, ok := users.Get(99); !ok {
        fmt.Println("99 is not cached")
    }

    flags := NewCache[string, bool]()
    flags.Set("dark-mode", true)
    on, _ := flags.Get("dark-mode")
    fmt.Println("dark-mode:", on)

    fmt.Println("sizes:", users.Len(), flags.Len())

    keys := []string{"dark-mode"}
    sort.Strings(keys)
    fmt.Println("flag keys:", keys)
}

users.Get(1) returns a User, not an any you have to assert. Everything you learned about mutexes and pointer receivers applies unchanged — generics add a type parameter and take nothing away.

Generic linked list

The classic case where the alternative is genuinely painful:

package main

import "fmt"

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

type List[T any] struct {
    head *Node[T]
    size int
}

func (l *List[T]) Prepend(v T) {
    l.head = &Node[T]{Value: v, Next: l.head}
    l.size++
}

func (l *List[T]) All() []T {
    out := make([]T, 0, l.size)
    for n := l.head; n != nil; n = n.Next {
        out = append(out, n.Value)
    }
    return out
}

func main() {
    var l List[string]
    l.Prepend("third")
    l.Prepend("second")
    l.Prepend("first")

    fmt.Println(l.All(), l.size)

    var nums List[int]
    nums.Prepend(2)
    nums.Prepend(1)
    fmt.Println(nums.All())
}

*Node[T] inside Node[T] — a generic type referring to itself — works exactly as you'd hope. And var l List[string] needs no constructor, because the zero value (nil head, size 0) is already a valid empty list. The useful-zero-value principle from module 5 survives generics.

The slices and maps packages

Go 1.21 shipped generic helpers so you rarely have to write Contains, Index or Sort yourself:

import (
    "maps"
    "slices"
)

nums := []int{3, 1, 2}
slices.Sort(nums)                       // [1 2 3]
i := slices.Index(nums, 2)              // 1
ok := slices.Contains(nums, 5)          // false
mx := slices.Max(nums)                  // 3
eq := slices.Equal(nums, []int{1, 2, 3}) // true
rev := slices.Reverse                    // in-place

m := map[string]int{"a": 1, "b": 2}
ks := slices.Sorted(maps.Keys(m))       // [a b]

Reach for these before writing your own — they're tested, they're fast, and slices.Equal finally answers "how do I compare two slices" from module 4.

(Those packages are newer than the interpreter running these lessons, so that box is reference-only. In a real Go 1.21+ program it all works.)

Your turn

Complete the generic QueueEnqueue adds to the back, Dequeue removes from the front and reports whether it succeeded:

a true
b true
 false
package main

import "fmt"

type Queue[T any] struct {
    items []T
}

// add Enqueue(v T) and Dequeue() (T, bool)

func main() {
    q := &Queue[string]{}
    q.Enqueue("a")
    q.Enqueue("b")

    fmt.Println(q.Dequeue())
    fmt.Println(q.Dequeue())
    fmt.Println(q.Dequeue())
}
package main

import "fmt"

type Queue[T any] struct {
    items []T
}

func (q *Queue[T]) Enqueue(v T) {
    q.items = append(q.items, v)
}

func (q *Queue[T]) Dequeue() (T, bool) {
    var zero T
    if len(q.items) == 0 {
        return zero, false
    }
    v := q.items[0]
    q.items = q.items[1:]
    return v, true
}

func main() {
    q := &Queue[string]{}
    q.Enqueue("a")
    q.Enqueue("b")

    fmt.Println(q.Dequeue())
    fmt.Println(q.Dequeue())
    fmt.Println(q.Dequeue())
}

Next — and this is the important one — when not to use any of this.