Generic Types

Updated

September 13, 2026

Generic Types

A generic type is a type that takes type parameters: Set[T], List[E]. The boring default for “a pile of unique IDs” is still map[T]struct{}. Wrap it in a named type when you have methods you want to keep next to the data, not because Set looks like computer science.

Mental model

type Set[T comparable] map[T]struct{} says: for any comparable T, a Set[T] is a map from T to empty struct. Instantiating Set[string] is a real type, distinct from Set[int]. Methods on Set[T] may use T. They may also declare their own type parameters (Go 1.27). That is for rare conversions. Most methods only need T.

Zero value: a nil map. Has on a nil Set[T] is fine (no keys). Add on a nil Set[T] panics. Provide NewSet or document “call Add only after make.”

Worked examples

Case 1: The map is already a set

Open tables this shift. Membership is the whole API. A named generic type would be extra surface.

Save as open_tables.go:

// open_tables.go
package main

import "fmt"

func main() {
    open := map[int]struct{}{
        3:  {},
        7:  {},
        12: {},
    }
    _, seated := open[7]
    _, wait := open[4]
    fmt.Printf("table 7 open: %t\n", seated)
    fmt.Printf("table 4 open: %t\n", wait)
    delete(open, 7)
    _, seated = open[7]
    fmt.Printf("table 7 after close: %t\n", seated)
}

Run:

go run open_tables.go

Output:

table 7 open: true
table 4 open: false
table 7 after close: false

Stay here if the set never grows methods. The empty struct uses no extra memory per key.

Case 2: Set[T comparable] with methods

The desk now has two sets: names on the roster, and table numbers. Same Add / Has / Len. That is the second copy. Name the type.

Save as set.go:

// set.go
package main

import "fmt"

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

func NewSet[T comparable]() Set[T] {
    return make(Set[T])
}

func (s Set[T]) Add(v T) {
    s[v] = struct{}{}
}

func (s Set[T]) Has(v T) bool {
    _, ok := s[v]
    return ok
}

func (s Set[T]) Len() int {
    return len(s)
}

func main() {
    roster := NewSet[string]()
    roster.Add("Amina")
    roster.Add("Bo")
    roster.Add("Amina")

    tables := NewSet[int]()
    tables.Add(3)
    tables.Add(12)

    fmt.Printf("roster len=%d has Bo=%t\n", roster.Len(), roster.Has("Bo"))
    fmt.Printf("tables len=%d has 7=%t\n", tables.Len(), tables.Has(7))
}

Run:

go run set.go

Output:

roster len=2 has Bo=true
tables len=2 has 7=false

Add has a value receiver on a map type: the map header is copied, the backing hash table is shared, so inserts are visible to the caller. NewSet makes the map. Calling var s Set[string]; s.Add("Amina") panics — nil map. That is why NewSet exists.

Case 3: A small collection type, not a framework

Tickets on a rail: push, peek, length. Generic because tickets and shift names both queue. Still a struct and three methods.

Save as rail.go:

// rail.go
package main

import "fmt"

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

func (r *Rail[T]) Push(v T) {
    r.items = append(r.items, v)
}

func (r *Rail[T]) Peek() (T, bool) {
    var zero T
    if len(r.items) == 0 {
        return zero, false
    }
    return r.items[0], true
}

func (r *Rail[T]) Len() int {
    return len(r.items)
}

func main() {
    var tickets Rail[int]
    tickets.Push(41)
    tickets.Push(42)
    id, ok := tickets.Peek()
    fmt.Printf("peek %d ok=%t len=%d\n", id, ok, tickets.Len())

    var names Rail[string]
    fmt.Printf("empty peek ok=%t\n", func() bool {
        _, ok := names.Peek()
        return ok
    }())
}

Run:

go run rail.go

Output:

peek 41 ok=true len=2
empty peek ok=false

Pointer receiver: Push must replace the slice header. T any because a rail does not compare elements. If you need Has, constrain T to comparable instead of adding a type assertion in the method.

Case 4: Methods that introduce a new type parameter (Go 1.27)

Before 1.27, a method could only use the receiver’s T. Mapping a rail of IDs to labels had to be a package-level function. Now a method may declare U. Use it when the conversion belongs to the type. Do not build a query engine.

Save as rail_labels.go:

// rail_labels.go
package main

import "fmt"

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

func (r *Rail[T]) Push(v T) {
    r.items = append(r.items, v)
}

func (r Rail[T]) Labels[U any](f func(T) U) []U {
    out := make([]U, len(r.items))
    for i, v := range r.items {
        out[i] = f(v)
    }
    return out
}

func main() {
    var tickets Rail[int]
    tickets.Push(41)
    tickets.Push(42)
    fmt.Println(tickets.Labels(func(id int) string {
        return fmt.Sprintf("T-%d", id)
    }))
}

Run:

go run rail_labels.go

Output:

[T-41 T-42]

Labels is a method so it reads as tickets.Labels(...). A package function labels(r Rail[int], f ...) is equally boring and easier to find in docs. Prefer the function if you only have one conversion.

The trap

A generic type whose API is just the built-in underneath. This Box[T] is a field. It does not earn the name.

Save as box.go:

// box.go
package main

import "fmt"

type Box[T any] struct {
    V T
}

func (b Box[T]) Get() T { return b.V }

func main() {
    n := Box[int]{V: 12}
    fmt.Println(n.Get())
}

Run:

go run box.go

Output:

12

Use int. Wrap a type when you have invariants or methods (a set that ignores duplicates, a rail that peeks). A single field named V is not an invariant.

The boring rule

  • map[T]struct{} first. Set[T] when Add/Has/Len show up in more than one file.
  • Constrain the type parameter to what methods need (comparable for map keys).
  • Nil maps: Has is safe, Add is not. Construct with make or NewSet.
  • Methods that only use T are ordinary methods. Methods that introduce U are a Go 1.27 feature — keep them rare.
  • Do not genericize a struct that always holds one concrete type at your desk.

Try this

  1. Add Remove(v T) to Set in set.go. Delete "Bo" and print Has("Bo") again.
  2. In rail.go, add Pop() (T, bool) that removes the front item. Pop twice from the ticket rail; the second should be 42.
  3. Replace Labels in rail_labels.go with a package-level function. Confirm the printed slice is unchanged.