Generic Constraints & Type Sets
Reviewed & published by Brayan K
Generics let you write one function or type that works for many element types using type parameters like [T any], while constraints — interfaces that list a set of permitted types — control which types are allowed and which operations the compiler will let you use on them.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Write type parameters: [T any], [T, U any]
- Build generic Map, Filter, and Reduce
- Use built-in constraints: any and comparable
- Define custom constraints with type sets (~int | ~float64)
- Create generic types like Stack[T]
- Rely on type inference at the call site
1️⃣ Generic functions: Map, Filter, Reduce
Type parameters go in square brackets after the name. Map[T, U any] turns a []T into a []U; any is the constraint that allows every type. At the call site you usually write nothing extra — inference reads the types straight from the arguments.
package main
import (
"fmt"
"strings"
)
// Map transforms a []T into a []U using f. [T, U any] are TYPE PARAMETERS:
// the function works for any element types, decided at the call site.
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, len(in))
for i, v := range in {
out[i] = f(v)
}
return out
}
// Filter keeps the elements for which keep returns true.
func Filter[T any](in []T, keep func(T) bool) []T {
var out []T
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
// Reduce folds a slice down to a single accumulated value.
func Reduce[T, U any](in []T, init U, f func(U, T) U) U {
acc := init
for _, v := range in {
acc = f(acc, v)
}
return acc
}
func main() {
nums := []int{1, 2, 3, 4, 5}
// Type inference: the compiler figures out T=int, U=string here.
words := Map(nums, func(n int) string { return strings.Repeat("*", n) })
fmt.Println("map: ", words)
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println("filter:", evens)
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
fmt.Println("reduce:", sum)
}2️⃣ Constraints & type sets (~int | ~float64)
A constraint is an interface that lists a type set. interface{ ~int | ~float64 } permits those types and lets you use +, -, and < on the value. The ~ admits named types built on those (so Celsius works). The built-in comparable permits == and !=.
package main
import "fmt"
// A CONSTRAINT is an interface listing the permitted type set. The ~ means
// "this type OR any type whose underlying type is this" — so a custom
// type like type Celsius float64 also satisfies Number.
type Number interface {
~int | ~int64 | ~float64
}
// Sum works for ANY type in the Number set. The constraint guarantees +
// is allowed on T.
func Sum[T Number](xs []T) T {
var total T // zero value of whatever T is
for _, x := range xs {
total += x
}
return total
}
type Celsius float64 // underlying type float64 -> satisfies ~float64
// comparable is a built-in constraint: types you can compare with == / !=.
// It's exactly what map keys need.
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
func main() {
fmt.Println("sum ints: ", Sum([]int{1, 2, 3}))
fmt.Println("sum floats: ", Sum([]float64{1.5, 2.5}))
fmt.Println("sum Celsius:", Sum([]Celsius{20, 1.5})) // custom type via ~
fmt.Println("contains 3? ", Contains([]int{1, 2, 3}, 3))
fmt.Println("contains x? ", Contains([]string{"a", "b"}, "x"))
}3️⃣ Generic types and an Ordered constraint
Types can be generic too: Stack[T any] is one definition that works for any element type, and its methods reference T. For Max we need the < operator, so we declare our own Ordered constraint (the standard library keeps the official one in an experimental module).
package main
import "fmt"
// Ordered lists the types that support < > (numbers and strings). This is
// the same shape as golang.org/x/exp/constraints.Ordered, defined here so
// we stay in the standard library.
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 | ~string
}
// Max needs the Ordered constraint so it can use the < operator.
func Max[T Ordered](xs []T) T {
m := xs[0]
for _, x := range xs[1:] {
if x > m {
m = x
}
}
return m
}
// Stack is a GENERIC TYPE: one definition, any element type.
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
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
func main() {
fmt.Println("max int: ", Max([]int{3, 9, 2}))
fmt.Println("max string:", Max([]string{"go", "rust", "c"}))
var s Stack[string]
s.Push("a")
s.Push("b")
top, _ := s.Pop()
fmt.Println("popped: ", top)
}🎯 Your Turn
Make a tiny generic First helper. Fill in the two blanks marked ___, then run it.
package main
import "fmt"
// 🎯 YOUR TURN — fill in the blanks marked ___
// 1) Give First a type parameter T constrained to any
func First[___](xs []T) (T, bool) { // 👉 T any
var zero T
if len(xs) == 0 {
return zero, false
}
return xs[0], true
}
func main() {
// 2) Call First with a slice of strings (inference fills T in)
v, ok := First(___) // 👉 []string{"go", "rust"}
fmt.Println(v, ok)
// ✅ Expected output:
// go true
}Common Errors (and the fix)
❌ Using + or < on a [T any] — "operator not defined".
✅ Constrain T to a type set that permits the operator (e.g. Number, Ordered).
❌ Writing int instead of ~int — named types like type ID int are rejected.
✅ Use the tilde so underlying types match.
❌ Inference fails when a type parameter appears only in the return type.
✅ Specify it explicitly: Make[string]().
❌ Using == on a [T any] — not all types are comparable (slices, maps, funcs aren't).
🧠 Quick Recall
constraint int vs ~int — which accepts type ID int?
~int accepts ID (its underlying type is int). Plain int matches only the exact int type.
which constraint lets you use == ?
comparable — the built-in constraint for types you can compare with ==/!=, exactly what map keys require.
🎯 Mini-Challenge: A Generic Min
Write Min[T Number] returning the smallest element, then call it on a slice of ints and a slice of float64s.
package main
import "fmt"
// Number permits ints and floats.
type Number interface {
~int | ~float64
}
// 🎯 MINI-CHALLENGE: write a generic Min
// Implement Min[T ...](xs []T) T that returns the smallest element, using a
// constraint that allows the < operator on numbers. Then call it on ints
// and on float64s.
//
// ✅ Expected output:
// min ints: 2
// min floats: 1.5
func main() {
ints := []int{7, 2, 9}
floats := []float64{3.0, 1.5, 4.0}
fmt.Println(ints, floats)
// your code here: define Min[T Number](xs []T) T and print the two results
}🎉 Lesson Complete!
- ✅ Type parameters [T any] let one function/type serve many types
- ✅ Constraints are interfaces with a type set that also permit operators
- ✅ ~int | ~float64 uses ~ to admit named types
- ✅ comparable enables ==; any enables only assignment
- ✅ Generic types like Stack[T]; inference fills parameters from arguments
Practice quiz
Where do type parameters go in a generic function?
- in parentheses before the name
- after the return type
- in square brackets after the name
- in a separate file
Answer: in square brackets after the name. Type parameters are written in square brackets after the name, e.g. Map[T, U any](...).
What is a constraint in Go generics?
- an interface that limits which types T accepts
- a runtime check
- a struct tag
- a panic guard
Answer: an interface that limits which types T accepts. A constraint is an interface listing the permitted type set and allowed operations.
What does the constraint any allow?
- only numeric types
- only comparable types
- nothing
- every type, but only assignment (no + < ==)
Answer: every type, but only assignment (no + < ==). any permits all types but guarantees no operations beyond moving values around.
What does the ~ in ~int mean?
- not int
- int or any type whose underlying type is int
- a pointer to int
- approximately int
Answer: int or any type whose underlying type is int. ~int admits named types like type Age int whose underlying type is int.
Which built-in constraint permits == and !=?
- comparable
- any
- Ordered
- Number
Answer: comparable. comparable allows == and != — exactly what map keys need.
Does func Eq[T any](a, b T) bool { return a == b } compile?
- yes
- only for ints
- no — any doesn't guarantee ==
- only at runtime
Answer: no — any doesn't guarantee ==. any can't use ==; the compiler reports incomparable types. Use comparable instead.
What does var total T give inside a generic function?
- nil
- the zero value of T
- a compile error
- a random value
Answer: the zero value of T. var total T is the zero value of whatever concrete type T becomes.
How does Sum(nums) usually know T without [int]?
- a default of int
- reflection at runtime
- it cannot
- type inference from the arguments
Answer: type inference from the arguments. Inference reads the type parameters straight from the ordinary arguments.
When does type inference fail?
- always for two parameters
- when a type parameter appears only in the return type
- for slices
- for strings
Answer: when a type parameter appears only in the return type. If T only shows up in the return type (not in any argument), you must specify it explicitly.
Can a type be generic, like Stack[T any]?
- no, only functions
- only built-in types
- yes, types can take type parameters too
- only with comparable
Answer: yes, types can take type parameters too. Types can be generic; Stack[T any] is one definition that works for any element type.
Continue this course
- Previous: Custom Errors & errors.Is/As
- Next: Embedding Files & Build Tags — Bundle assets into the binary with //go:embed and use build constraints
Frequently asked questions
What is a type parameter and how is it written?
A type parameter is a placeholder for a type, written in square brackets after the function or type name: func Map[T, U any](...) or type Stack[T any]. Inside the body you use T and U like real types. At the call site the compiler picks the concrete types — often automatically through inference — so one definition works for many types without interface{} and runtime type assertions.
What exactly is a constraint?
A constraint is an interface that limits which types a type parameter accepts AND tells the compiler what operations are allowed on it. 'any' allows every type (but no operations beyond assignment). 'comparable' allows == and !=. A custom constraint like interface{ ~int | ~float64 } both restricts T to those underlying types and lets you use + and < on it.
What does the ~ (tilde) mean in a type set?
Without ~, the constraint matches only the exact type — ~int written as just int would reject a user type like 'type Age int'. The ~ means 'this type OR any type whose underlying type is this', so ~int also admits Age, Celsius, and any other named type built on int. Use ~ whenever you want your generic to work with named types, which is almost always.
Why doesn't the standard library export constraints.Ordered?
It lives in golang.org/x/exp/constraints, an experimental module, not the standard library — so to stay dependency-free you can declare your own Ordered interface listing the numeric and string types with ~. The lesson does exactly that. If you don't mind the extra module, importing the official one saves typing the long type set.
What is type inference and when does it fail?
Inference lets the compiler deduce the type parameters from the arguments, so you write Map(nums, f) instead of Map[int, string](nums, f). It works whenever the types appear in the ordinary parameters. It can't infer a type parameter that only shows up in the RETURN type or isn't used in any argument — then you must specify it explicitly in brackets.
When should I reach for generics versus an interface?
Use generics when the logic is identical across types and you want to keep the concrete type (no boxing, no assertions) — containers, Map/Filter/Reduce, Min/Max. Use an interface when you want different types to provide different BEHAVIOUR behind a common method set (like io.Writer). A good rule: generics for 'same code, many types', interfaces for 'many implementations, one contract'.