Go Generics in Practice: Type Parameters, Constraints, and Real-World Patterns
Go added generics (type parameters) in version 1.18, and by 2026 the ecosystem has converged on clear patterns for when and how to use them effectively. This guide covers the practical aspects: from basic syntax through real-world collection implementations to understanding the performance implications.
Basic Generic Functions
The simplest use of generics is a function that works for multiple types:
package main
import (
"cmp"
"fmt"
)
// Without generics: separate functions
func MinInt(a, b int) int {
if a < b { return a }
return b
}
// With generics using cmp.Ordered constraint
func Min[T cmp.Ordered](a, b T) T {
if a < b { return a }
return b
}
func main() {
fmt.Println(Min(3, 5))
fmt.Println(Min(3.14, 2.71))
fmt.Println(Min("apple", "banana"))
}
Type Constraints
Constraints restrict which types can be used as type arguments:
type Number interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
// ~T: underlying type constraint (includes named types)
type MyInt int
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
func Double[T Integer](x T) T {
return x * 2
}
func main() {
ints := []int{1, 2, 3, 4, 5}
floats := []float64{1.1, 2.2, 3.3}
fmt.Println(Sum(ints)) // 15
fmt.Println(Sum(floats)) // 6.6
var m MyInt = 5
fmt.Println(Double(m)) // 10
}
The comparable and any Constraints
comparable allows == and != operations, enabling maps and equality checks:
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{items: make(map[T]struct{})}
}
func (s *Set[T]) Add(item T) {
s.items[item] = struct{}{}
}
func (s *Set[T]) Contains(item T) bool {
_, ok := s.items[item]
return ok
}
func (s *Set[T]) Remove(item T) {
delete(s.items, item)
}
func (s *Set[T]) Size() 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
}
Generic Collections
Type-Safe 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) {
if len(s.items) == 0 {
var zero T
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) {
if len(s.items) == 0 {
var zero T
return zero, false
}
return s.items[len(s.items)-1], true
}
Generic Queue
type Queue[T any] struct {
items []T
head int
}
func (q *Queue[T]) Enqueue(item T) {
q.items = append(q.items, item)
}
func (q *Queue[T]) Dequeue() (T, bool) {
if q.head >= len(q.items) {
var zero T
return zero, false
}
item := q.items[q.head]
q.head++
// Compact when head advances far enough
if q.head > len(q.items)/2 {
q.items = q.items[q.head:]
q.head = 0
}
return item, true
}
Generic Utility Functions
The slices and maps packages from the standard library (Go 1.21+) provide generic utilities:
import (
"slices"
"maps"
"cmp"
)
func main() {
nums := []int{3, 1, 4, 1, 5, 9, 2, 6}
// Sort with generics
slices.Sort(nums)
fmt.Println(nums) // [1 1 2 3 4 5 6 9]
// Binary search
idx, found := slices.BinarySearch(nums, 5)
fmt.Printf("Found 5 at index %d: %v\n", idx, found)
// Map operations
m := map[string]int{"a": 1, "b": 2, "c": 3}
keys := slices.Sorted(maps.Keys(m))
fmt.Println(keys)
}
Custom generic utilities:
// Filter: returns elements matching predicate
func Filter[T any](slice []T, predicate func(T) bool) []T {
result := make([]T, 0)
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
// Map: transform each element
func Map[T, U any](slice []T, transform func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = transform(v)
}
return result
}
// Reduce: fold slice to single value
func Reduce[T, U any](slice []T, initial U, combine func(U, T) U) U {
result := initial
for _, v := range slice {
result = combine(result, v)
}
return result
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4 6 8 10]
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled)
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
fmt.Println(sum) // 55
}
When to Use Generics
Guidelines for when generics add value vs. when to stick with interfaces:
// USE GENERICS when:
// 1. The algorithm is identical for multiple types
func Contains[T comparable](slice []T, item T) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
// 2. You need type-safe collections
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
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
}
// USE INTERFACES when:
// The behavior varies by type (polymorphism)
type Writer interface {
Write(p []byte) (n int, err error)
}
// Interfaces also allow runtime dispatch; generics are compile-time
Performance Implications
Generics in Go are implemented using GC shapes (dictionary passing), which can have performance implications:
// Benchmarking generic vs. specific function
// Generally: generics with concrete types compile to optimized code
// Interface-based: one implementation, runtime dispatch
// Profile-critical code: consider specific implementations
func SumInt(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Generic version - may be slightly slower due to GC shape overhead
// for very hot loops, but negligible for most use cases
func SumGeneric[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
Practical Generic Patterns
Result type for error handling
type Result[T any] struct {
value T
err error
}
func Ok[T any](v T) Result[T] {
return Result[T]{value: v}
}
func Err[T any](err error) Result[T] {
return Result[T]{err: err}
}
func (r Result[T]) Unwrap() T {
if r.err != nil {
panic(r.err)
}
return r.value
}
func (r Result[T]) IsOk() bool {
return r.err == nil
}
Optional type
type Option[T any] struct {
value *T
}
func Some[T any](v T) Option[T] {
return Option[T]{value: &v}
}
func None[T any]() Option[T] {
return Option[T]{}
}
func (o Option[T]) IsSome() bool {
return o.value != nil
}
func (o Option[T]) Unwrap() T {
if o.value == nil {
panic("called Unwrap on None")
}
return *o.value
}
Conclusion
Go generics shine for type-safe collections, utility functions, and algorithms that are structurally identical across types. The key is restraint: use generics when you have a concrete need to eliminate code duplication while maintaining type safety. Don't generify everything—interfaces remain the right tool when runtime polymorphism is needed. By 2026, the patterns are clear: generics for data structures and algorithms, interfaces for behavior.