Generic Types
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
[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.goOutput:
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 (
comparablefor map keys). - Nil maps:
Hasis safe,Addis not. Construct withmakeorNewSet. - Methods that only use
Tare ordinary methods. Methods that introduceUare a Go 1.27 feature — keep them rare. - Do not genericize a struct that always holds one concrete type at your desk.
Try this
- Add
Remove(v T)toSetinset.go. Delete"Bo"and printHas("Bo")again. - In
rail.go, addPop() (T, bool)that removes the front item. Pop twice from the ticket rail; the second should be42. - Replace
Labelsinrail_labels.gowith a package-level function. Confirm the printed slice is unchanged.