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 parameter T. T must satisfy constraint Number.
  • Number is an interface containing a type set: int, int64, float64 and types derived from them (the meaning of ~ is in section 4.3).
  • var total T creates the zero value of T, that is 0 or 0.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.
  • any is an alias for interface{}. It allows any type, but you can’t use any operators on the value except assignment and passing it on.
  • The compiler infers T = Order and U = string from the function func(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.Ordered lets 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/min functions, and the slices package has slices.Max, slices.Contains, slices.Sort. Check out slices and maps before 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 because append works 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, PaymentService taking a Notifier with a Send method. 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, or cmp already 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 like VND.
  • Building generic types Stack[T] and Cache[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.

golanggenericstype parameterconstraint