Generics (Go 1.18+) Advanced¶
Introduction¶
Go 1.18 introduced generics (type parameters), the most significant language change since Go 1.0. Generics allow you to write functions and types that work with any type satisfying a constraint, eliminating the need for interface{} casts, code duplication, or code generation for type-safe abstractions.
Before generics, Go developers faced a trade-off: type safety (writing separate IntStack, StringStack) vs. reusability (interface{} with runtime type assertions). Generics resolve this tension — you get both.
Syntax & Usage¶
Type Parameters and Constraints¶
// Basic generic function
// T is a type parameter, 'any' is the constraint
func Print[T any](value T) {
fmt.Println(value)
}
// Multiple type parameters
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Calling generic functions
Print[int](42) // Explicit type argument
Print("hello") // Type inference — compiler deduces T = string
doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })
Built-in Constraints¶
// 'any' — alias for interface{}, allows any type
func Identity[T any](v T) T { return v }
// 'comparable' — types that support == and !=
// Required for map keys, equality checks
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
Custom Constraints¶
// Constraint interface — defines what operations T must support
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
// The ~ (tilde) means "underlying type"
type Celsius float64
type Fahrenheit float64
// Without ~float64, Celsius and Fahrenheit would NOT satisfy the constraint
// With ~float64, any type whose underlying type is float64 satisfies it
fmt.Println(Sum([]Celsius{20.5, 30.1})) // Works because of ~float64
Constraint Interfaces with Methods¶
type Stringer interface {
~int | ~string
String() string
}
// Combine type terms and method requirements
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 | ~string
}
func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}
The constraints Package (golang.org/x/exp/constraints)¶
import "golang.org/x/exp/constraints"
// Pre-defined constraints:
// constraints.Signed — all signed integer types
// constraints.Unsigned — all unsigned integer types
// constraints.Integer — Signed | Unsigned
// constraints.Float — ~float32 | ~float64
// constraints.Complex — ~complex64 | ~complex128
// constraints.Ordered — Integer | Float | ~string
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
Generic Types¶
// 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) {
var zero T
if len(s.items) == 0 {
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) {
var zero T
if len(s.items) == 0 {
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, ok := intStack.Pop() // val=2, ok=true
Generic Set¶
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable](values ...T) *Set[T] {
s := &Set[T]{items: make(map[T]struct{})}
for _, v := range values {
s.Add(v)
}
return s
}
func (s *Set[T]) Add(v T) { s.items[v] = struct{}{} }
func (s *Set[T]) Remove(v T) { delete(s.items, v) }
func (s *Set[T]) Contains(v T) bool { _, ok := s.items[v]; return ok }
func (s *Set[T]) Len() int { return len(s.items) }
func (s *Set[T]) Union(other *Set[T]) *Set[T] {
result := NewSet[T]()
for k := range s.items {
result.Add(k)
}
for k := range other.items {
result.Add(k)
}
return result
}
func (s *Set[T]) Intersection(other *Set[T]) *Set[T] {
result := NewSet[T]()
for k := range s.items {
if other.Contains(k) {
result.Add(k)
}
}
return result
}
Result Type (Rust-inspired)¶
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]) Unwrap() (T, error) {
return r.value, r.err
}
func (r Result[T]) IsOk() bool { return r.err == nil }
func (r Result[T]) Map(fn func(T) T) Result[T] {
if r.err != nil {
return r
}
return Ok(fn(r.value))
}
func (r Result[T]) OrElse(defaultVal T) T {
if r.err != nil {
return defaultVal
}
return r.value
}
// Usage
result := Ok(42).Map(func(v int) int { return v * 2 })
value := result.OrElse(0) // 84
Functional Utilities¶
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}
func GroupBy[T any, K comparable](slice []T, keyFn func(T) K) map[K][]T {
result := make(map[K][]T)
for _, v := range slice {
key := keyFn(v)
result[key] = append(result[key], v)
}
return result
}
// Usage
evens := Filter([]int{1, 2, 3, 4, 5}, func(n int) bool { return n%2 == 0 })
sum := Reduce([]int{1, 2, 3, 4}, 0, func(acc, n int) int { return acc + n })
Generic Repository Pattern¶
type Entity interface {
comparable
GetID() string
}
type Repository[T Entity] struct {
store map[string]T
mu sync.RWMutex
}
func NewRepository[T Entity]() *Repository[T] {
return &Repository[T]{store: make(map[string]T)}
}
func (r *Repository[T]) Create(entity T) error {
r.mu.Lock()
defer r.mu.Unlock()
id := entity.GetID()
if _, exists := r.store[id]; exists {
return fmt.Errorf("entity %s already exists", id)
}
r.store[id] = entity
return nil
}
func (r *Repository[T]) FindByID(id string) (T, error) {
r.mu.RLock()
defer r.mu.RUnlock()
entity, ok := r.store[id]
if !ok {
var zero T
return zero, fmt.Errorf("entity %s not found", id)
}
return entity, nil
}
func (r *Repository[T]) FindAll(predicate func(T) bool) []T {
r.mu.RLock()
defer r.mu.RUnlock()
var results []T
for _, entity := range r.store {
if predicate(entity) {
results = append(results, entity)
}
}
return results
}
func (r *Repository[T]) Delete(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.store[id]; !exists {
return fmt.Errorf("entity %s not found", id)
}
delete(r.store, id)
return nil
}
// Usage
type User struct {
ID string
Name string
Age int
}
func (u User) GetID() string { return u.ID }
repo := NewRepository[User]()
repo.Create(User{ID: "1", Name: "Alice", Age: 30})
user, _ := repo.FindByID("1")
adults := repo.FindAll(func(u User) bool { return u.Age >= 18 })
The slices and maps Packages (stdlib since Go 1.21)¶
import (
"slices"
"maps"
)
// slices package
nums := []int{3, 1, 4, 1, 5, 9}
slices.Sort(nums) // [1, 1, 3, 4, 5, 9]
idx, found := slices.BinarySearch(nums, 4) // idx=3, found=true
slices.Reverse(nums)
slices.Contains(nums, 5) // true
compact := slices.Compact([]int{1, 1, 2, 2, 3}) // [1, 2, 3]
// maps package
m := map[string]int{"a": 1, "b": 2, "c": 3}
keys := maps.Keys(m) // unordered slice of keys
values := maps.Values(m) // unordered slice of values
maps.DeleteFunc(m, func(k string, v int) bool { return v < 2 })
clone := maps.Clone(m)
maps.Equal(m, clone) // true
Quick Reference¶
| Concept | Syntax | Example |
|---|---|---|
| Type parameter | func F[T constraint]() |
func Print[T any](v T) |
| Multiple params | func F[T, U constraint]() |
func Map[T any, U any](...) |
| Any type | any |
[T any] |
| Equality support | comparable |
[T comparable] |
| Custom constraint | type C interface { ... } |
type Number interface { ~int \| ~float64 } |
| Underlying type | ~T in constraint |
~int matches type MyInt int |
| Union types | T1 \| T2 in constraint |
~int \| ~string |
| Generic type | type S[T constraint] struct{} |
type Stack[T any] struct{} |
| Zero value | var zero T |
Return default for type param |
| Type inference | Omit [T] at call site |
Print("hello") vs Print[string]("hello") |
Best Practices¶
-
Start without generics — write concrete code first, then generalize only when you see clear duplication across multiple types.
-
Use the narrowest constraint possible — if you only need equality, use
comparable, notany. -
Prefer stdlib packages — use
slices,maps, andcmpbefore writing your own generic utilities. -
Name constraints meaningfully —
Number,Ordered,Entityare better thanT1,C. -
Use type inference — let the compiler infer type arguments when it can:
Max(1, 2)overMax[int](1, 2). -
Document constraints — explain why a constraint is needed, especially for custom ones.
Common Pitfalls¶
No Method-Level Type Parameters
Go does not allow type parameters on methods — only on functions and type definitions.
Zero Value Trap
Generic code cannot use typed literals like 0 or "". Use var zero T for the zero value.
No Specialization
You cannot provide a specialized implementation of a generic function for a particular type. Every instantiation uses the same code.
Comparable Gotcha with Interfaces
Interface types satisfy comparable at compile time, but may panic at runtime if the underlying value is not comparable (e.g., a slice inside an interface).
Performance Considerations¶
-
Monomorphization vs. GCShape stenciling: Go does NOT fully monomorphize like C++ templates or Rust generics. Instead, it uses GCShape stenciling — types with the same GC shape (pointer vs. non-pointer) share a single compiled function. This reduces binary size but can add indirection overhead for pointer-shaped types.
-
Pointer-shaped types share one implementation:
Stack[*User]andStack[*Order]use the same generated code with dictionary-based dispatch. Value types likeStack[int]andStack[float64]may get separate implementations. -
Interface{} boxing may still occur in some generic code paths, though the compiler is improving. Profile before assuming generics are zero-cost.
-
Compilation time: Generics can increase compile time, especially with complex constraint combinations. Generally negligible for most projects.
-
Benchmark the hot path: For performance-critical code, benchmark generic vs. concrete implementations. In most cases the difference is under 5%, but in tight loops with pointer-shaped types, the dictionary lookup overhead can matter.
// Benchmark example
func BenchmarkGenericSum(b *testing.B) {
data := make([]int, 1000)
for i := range data { data[i] = i }
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sum(data) // generic
}
}
func BenchmarkConcreteSum(b *testing.B) {
data := make([]int, 1000)
for i := range data { data[i] = i }
b.ResetTimer()
for i := 0; i < b.N; i++ {
sumInts(data) // concrete
}
}
Interview Tips¶
Interview Tip
When asked about generics, frame them in the historical context: before 1.18, Go relied on interface{} (no type safety), code generation (go generate), or copy-paste. Generics solve the type-safe reusability problem. Mention the design philosophy — Go generics are deliberately simpler than C++ templates or Rust traits, favoring readability over power.
Interview Tip
A common question is "When would you NOT use generics?" Good answers: when there's only one concrete type, when the logic differs per type (use interfaces), when any/interface{} already suffices (no type safety gained), or when code generation is already established in the project.
Interview Tip
If asked about performance, explain GCShape stenciling: Go doesn't create a new function for every type instantiation like C++. Types with the same "shape" (e.g., all pointer types) share one implementation. This trades some runtime performance for smaller binary size — a deliberate trade-off reflecting Go's priorities.
Key Takeaways¶
- Generics enable type-safe reusable code without sacrificing performance or readability.
- Constraints define what operations a type parameter supports — use the narrowest constraint that works.
- The tilde operator (
~int) matches types whose underlying type matches, critical for user-defined types. - Go generics are intentionally limited — no method type parameters, no specialization, no variadic type parameters.
- Prefer concrete code until you see clear duplication, then generalize with generics.
- The
slices,maps, andcmpstdlib packages provide battle-tested generic utilities — use them.