Generics in Go: type parameters through examples
1. Overview
Starting from Go 1.18, you can write functions and data types with type parameters. This way, you don’t have to duplicate the same logic for int, float64, string, and you don’t have to cast from interface{}.
This post goes from the initial problem to generic functions, constraints, and the ~ notation. Then comes generic types like Stack[T] and Cache[K, V], how to write tests, and common mistakes. After reading, you’ll know when to use generics and when a regular interface is enough.
All examples use Go 1.22 and above. The cmp package is available from Go 1.21. Minimum go.mod file:
module example.com/shop
go 1.22
No external libraries needed. Everything in this post is from the standard library.
2. The problem before generics
Suppose you need to calculate the total quantity of items (int) and the total amount (float64). Before Go 1.18, you usually wrote two identical functions:
func SumInts(xs []int) int {
var total int
for _, x := range xs {
total += x
}
return total
}
func SumFloats(xs []float64) float64 {
var total float64
for _, x := range xs {
total += x
}
return total
}
The other way is to accept []interface{} and use type switch. This loses type safety. Errors only appear at runtime, and you have to cast at every call site.
Generics solve exactly this: write the logic once, and the compiler still checks types for you.
3. Your first generic function
3.1. Type parameter syntax
Type parameters are declared in square brackets, right after the function name:
package main
import "fmt"
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func main() {
quantities := []int{2, 1, 5}
prices := []float64{120.5, 99.9, 15}
fmt.Println(Sum(quantities))
fmt.Println(Sum(prices))
}
Explanation piece by piece:
[T Number]declares type parameterT.Tmust satisfy constraintNumber.Numberis an interface containing a type set:int,int64,float64and types derived from them (the meaning of~is in section 4.3).var total Tcreates the zero value ofT, that is0or0.0.- The
+=operator is valid because every type in the type set supports addition.
Output:
8
235.4
3.2. Type inference
Did you notice we called Sum(quantities) instead of Sum[int](quantities)? The compiler infers T from the argument. That’s type inference.
You only need to write it explicitly when the compiler doesn’t have enough information to infer it, for example when the type parameter only appears in the return type:
func Zero[T any]() T {
var z T
return z
}
func main() {
fmt.Println(Zero[string]() == "") // true
}
3.3. Multiple type parameters: Map and Filter
These are the two most common utility functions. Let’s apply them to a list of Order:
package main
import "fmt"
type Order struct {
ID string
Customer string
Total float64
}
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}
func main() {
orders := []Order{
{"A01", "Lan", 250_000},
{"A02", "Minh", 1_200_000},
{"A03", "Lan", 890_000},
}
big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
ids := Map(big, func(o Order) string { return o.ID })
fmt.Println(ids)
}
Map[T, U any]has two type parameters, so the input and output types can be different.anyis an alias forinterface{}. It allows any type, but you can’t use any operators on the value except assignment and passing it on.- The compiler infers
T = OrderandU = stringfrom the functionfunc(o Order) string.
Output:
[A02 A03]
4. Constraints: limiting what T can do
Constraints determine what you can do with a value of type T inside the function.
4.1. comparable
comparable is a built-in constraint. It lets you use == and !=, and it’s also the requirement for T to be a map key:
func Unique[T comparable](xs []T) []T {
seen := make(map[T]struct{}, len(xs))
var out []T
for _, x := range xs {
if _, ok := seen[x]; !ok {
seen[x] = struct{}{}
out = append(out, x)
}
}
return out
}
// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]
The Order struct above is also comparable because all its fields are comparable. A struct containing a slice, map, or func does not satisfy comparable, and the compiler will report an error.
4.2. cmp.Ordered
When you need to compare greater than or less than, use cmp.Ordered from the standard library:
import "cmp"
func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
var best T
if len(xs) == 0 {
return best, false
}
best = xs[0]
for _, x := range xs[1:] {
if key(x) > key(best) {
best = x
}
}
return best, true
}
// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
K cmp.Orderedlets you use>on the key value.- The function returns
(T, bool)to handle empty slices without panicking.
Note: For simple slices of numbers, you don’t need to write your own. Go 1.21 already has built-in
max/minfunctions, and theslicespackage hasslices.Max,slices.Contains,slices.Sort. Check outslicesandmapsbefore writing your own generic utilities.
4.3. The ~ (tilde) notation
In the real world, you often define your own type for money:
type VND int64
func main() {
payments := []VND{150_000, 350_000}
fmt.Println(Sum(payments)) // 500000
}
This compiles because of ~int64 in Number. The notation ~int64 means “any type with underlying type int64”. If you write int64 without ~, then VND doesn’t satisfy the constraint, and the compiler reports:
VND does not satisfy Number (possibly missing ~ for int64 in Number)
Simple rule: when writing constraints for basic types, you should almost always use ~.
4.4. Constraints with methods
Constraints can also require methods, just like regular interfaces:
type Identifiable interface {
comparable
Key() string
}
Any type that wants to satisfy Identifiable must be comparable and have a method Key() string.
5. Generic types
5.1. Stack[T]
Type parameters aren’t just for functions; they also work for structs:
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 := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
Usage:
var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})
o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
- The receiver is written as
*Stack[T], must include[T]. - When declaring a variable, you must specify the type:
Stack[Order]. There’s no inference here. - The zero value of
Stack[Order]is immediately usable becauseappendworks fine on a nil slice.
5.2. Cache[K, V] goroutine-safe
A more realistic example: an in-memory cache shared between Customer and Order.
package cache
import "sync"
type Cache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func New[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}
func (c *Cache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
type Customer struct {
ID int
Name string
}
customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})
orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})
c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true
One implementation serves two types of data. customers.Get returns the correct Customer type, no type assertion needed. If you accidentally Put an Order into customers, the code won’t compile.
6. Writing tests for generic code
Go uses the standard testing package. Table-driven tests still work normally with generic functions. Each test table corresponds to a specific type:
package main
import "testing"
func TestSum(t *testing.T) {
ints := []struct {
name string
in []int
want int
}{
{"empty", nil, 0},
{"positive", []int{2, 1, 5}, 8},
{"mixed", []int{10, -3}, 7},
}
for _, tc := range ints {
t.Run(tc.name, func(t *testing.T) {
if got := Sum(tc.in); got != tc.want {
t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
}
})
}
if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
t.Errorf("Sum VND = %d, want 500000", got)
}
}
func TestStackPopEmpty(t *testing.T) {
var s Stack[string]
v, ok := s.Pop()
if ok || v != "" {
t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
}
}
Run tests:
go test ./... -v
=== RUN TestSum
=== RUN TestSum/empty
=== RUN TestSum/positive
=== RUN TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS
Test at least one derived type like VND. This is the quickest way to spot a missing ~ in your constraint.
7. Common mistakes and when not to use generics
Methods cannot have their own type parameters. This doesn’t compile:
// error: methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }
Write it as a top-level function instead: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].
Don’t use type switch directly on T. To branch on type, convert to any first: switch v := any(x).(type) { ... }. If you find yourself doing this frequently, generics might not be the right tool.
Constraints with type sets can’t be used as variable types. var n Number will error. Interfaces with | or ~ can only be used as constraints.
comparable with interface types can panic. If K is an interface type and its dynamic value is a slice, comparison or using it as a map key will panic at runtime.
Note: Generics are not automatically faster than interfaces. The Go compiler uses “GC shape stenciling” with dictionaries. Different pointer types may share one code path, and method calls through constraints still have indirect costs. If performance matters, benchmark instead of guessing.
When NOT to use generics:
- When logic depends on behavior (methods), not on type. For example,
PaymentServicetaking aNotifierwith aSendmethod. Interfaces are usually cleaner and easier to read. - When only one concrete type uses that function. Don’t generify “just in case” later.
- When
slices,maps, orcmpalready have the function you need.
An easy rule: if you’re writing the same code for multiple types and it only differs in type names, that’s when to use generics.
8. Conclusion
This post has covered the main parts of generics in Go:
- Declaring type parameters
[T C]and letting the compiler infer types. - Using constraints like
any,comparable,cmp.Ordered, and defining your own with~to support derived types likeVND. - Building generic types
Stack[T]andCache[K, V]that are safe for multiple goroutines. - Writing table-driven tests for generic functions.
- Recognizing the limits: methods can’t have their own type parameters, constraints can’t be variable types, and generics don’t replace interfaces.
Start where you have real code duplication in your project. Write generics minimally, prefer the standard library, and always test with at least two different types.