For example, you would write a function to sum a map[string]int64, then the exact same function again but for map[string]float64. Generics remove the need to duplicate that logic — you write it once and let it work for both.
// Without Generics: two nearly identical functions
func SumInts(m map[string]int64) int64 {
var s int64
for _, v := range m {
s += v
}
return s
}
func SumFloats(m map[string]float64) float64 {
var s float64
for _, v := range m {
s += v
}
return s
}
interface{} + type assertions (loses compile-time safety), or using the reflect package (slow, error-prone). Generics give you the reuse without giving up type safety.A generic function declares type parameters in square brackets [] right after the function name, before the regular parameters. This is what makes the function generic instead of tied to one concrete type.
// T is a type parameter, "any" is its constraint
// K, V are two type parameters used together
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}
K comparable and V int64 | float64 are type parameters — placeholders for real types that get filled in later.comparable and int64 | float64 are type constraints — they define which types are allowed to be used for K and V (more on this below).
comparable constraint means the key type must support == and !=. This is required because Go maps only allow comparable types as keys.K and V behave like normal types — you can declare variables of type V, use them as map keys/values, etc.To call a generic function, you can explicitly pass the concrete types as type arguments in [], the same way you declared type parameters:
ints := map[string]int64{"first": 34, "second": 12}
// explicitly passing type arguments: K=string, V=int64
SumIntsOrFloats[string, int64](ints)
In most cases you can drop the type arguments entirely and let the compiler figure them out from the arguments you pass — this is called type inference (covered further down).
// compiler infers K=string, V=int64 from the map you pass in
SumIntsOrFloats(ints)
Structs, not just functions, can take type parameters. This lets you build reusable data structures (stacks, trees, linked lists, pairs, etc.) that work with any type.
// T is a type parameter on the struct itself
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
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
(s *Stack[T]), not just (s *Stack) — you carry the type parameter along whenever you reference the generic type.To use a generic type, you instantiate it with a concrete type in [], just like a generic function call:
intStack := Stack[int]{}
intStack.Push(10)
intStack.Push(20)
stringStack := Stack[string]{}
stringStack.Push("hello")
Generic types can be nested inside other generic types — a type parameter declared on one generic type/function can be passed straight into another:
// A generic key-value pair
type Entry[K, V any] struct {
Key K
Value V
}
// Returns a slice of Entry, reusing K and V from the map passed in
func Entries[K comparable, V any](m map[K]V) []Entry[K, V] {
e := make([]Entry[K, V], 0, len(m))
for k, v := range m {
e = append(e, Entry[K, V]{Key: k, Value: v})
}
return e
}
A type constraint is an interface that limits which types are allowed to fill a type parameter. It's the compiler's way of guaranteeing the generic code can safely perform whatever operations it does on that type.
x < y on a type parameter T, the constraint on T must guarantee ordering — you can't do that on a constraint like any.comparable is a predeclared constraint that allows any type usable with == and !=. It's required whenever a type parameter is used as a map key, since Go requires map keys to be comparable.
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
any is a constraint too — it's an alias for interface{}, meaning "zero methods required," so literally any type satisfies it. Use it when the generic code truly doesn't care what the type is (e.g., just storing or returning it).
You can build your own constraint as a named interface, using | to specify a union of allowed types. This is preferred over inlining the union directly in the function signature once it gets reused more than once.
type Number interface {
int64 | float64
}
// Reuse Number instead of writing "int64 | float64" everywhere
func SumNumbers[K comparable, V Number](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}
Constraints can also require specific methods, exactly like a regular interface — the type argument just has to implement them:
type Stringer interface {
String() string
}
// T can only be used here in ways Stringer allows — e.g. calling .String()
func Describe[T Stringer](v T) string {
return v.String()
}
The ~ operator (type approximation) extends a constraint to also allow any type whose underlying type matches — not just the exact named type. Without ~, a custom named type like type Point int would NOT satisfy a constraint of int.
type Number interface {
~int | ~int64 | ~float64 // any type whose underlying type is int, int64, or float64
}
type Point int // underlying type is int
func Min[T Number](x, y T) T {
if x < y {
return x
}
return y
}
Min(Point(5), Point(2)) // works because of ~int
Constraints can be composed from other constraints — this keeps them small and reusable, similar to embedding interfaces:
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
type Float interface {
~float32 | ~float64
}
type Number interface {
Integer | Float
}