Go Advanced #3 Generics — Type Parameters and Constraints

6 min read

After #2 sync Package, this time a tool of a different flavor. Generics.

Introduced in Go 1.18 (2022). Patterns that previously required interface{} (now any) and type assertions can now be expressed in a type-safe way.

Where generics fit #

Min that works on any type
func Min[T int | float64 | string](a, b T) T {
	if a < b {
		return a
	}
	return b
}

Min(1, 2)         // 1
Min(1.5, 2.5)     // 1.5
Min("a", "b")     // "a"

[T int | float64 | string] is the type parameter. T is determined at call time.

Two key parts #

  • type parameter list[T constraint, U constraint, ...] after the function name
  • constraint — what T must satisfy. Expressed via interfaces

Constraint — interfaces, extended #

Existing interfaces only expressed a method set. With generics, an interface can also express a type set.

constraint as a type set
type Number interface {
	int | float64
}

func Sum[T Number](nums []T) T {
	var sum T
	for _, n := range nums {
		sum += n
	}
	return sum
}

The Number interface is satisfied only by types that are int or float64. It works as a constraint even with no methods.

~ token — include underlying-type-based types #

~ token
type Number interface {
	~int | ~float64
}

type Celsius float64
Sum([]Celsius{1, 2, 3})    // OK — Celsius's underlying type is float64

~int — every type whose underlying type is int. Includes named types like type ID int.

Without ~, plain int | float64 — defined types like Celsius aren’t usable.

comparable — types that compare with == and != #

The set of types that support == !=. Appears in places like map keys.

comparable
func Index[T comparable](s []T, target T) int {
	for i, v := range s {
		if v == target {
			return i
		}
	}
	return -1
}

comparable is a predefined constraint that includes int, string, and structs (when all fields are comparable), but slices, maps, and functions are excluded because they do not support ==.

any — no constraint #

any constraint
func First[T any](s []T) T {
	return s[0]
}

any is an alias for interface{} and accepts any type. It is useful only when you perform almost no operations on the type — just indexing or assignment.

golang.org/x/exp/constraints — standard constraints #

Common constraints are predefined in a separate package.

exp/constraints
import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

constraints.Ordered — types that support < > <= >= (numbers + string).

Generic data structures #

The most natural fit. Data structures usually behave the same regardless of the contained type.

generic Stack
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) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func main() {
	s := Stack[int]{}
	s.Push(1)
	s.Push(2)
	v, _ := s.Pop()    // 2
	_ = v
}

var zero T — the standard pattern for getting a generic type’s zero value.

Type inference for generic functions #

type inference
Sum([]int{1, 2, 3})           // T inferred as int
Sum[int]([]int{1, 2, 3})      // explicit

stack := Stack[int]{}          // type argument explicit

Function calls — auto-inferred from argument types. Data structures or method calls — often need explicit type arguments.

Standard library use — slices, maps #

In Go 1.21, generic-based standard packages were added.

slices package
import "slices"

slices.Contains([]int{1, 2, 3}, 2)         // true
slices.Sort([]string{"b", "a", "c"})        // sort
slices.Index([]int{1, 2, 3}, 2)            // 1
slices.Reverse([]int{1, 2, 3})              // [3 2 1]

import "maps"
maps.Keys(m)                                 // all keys of a map

Helpers you used to write by hand — now in the standard library.

Pitfall — methods can’t introduce type parameters #

common pitfall
type Box[T any] struct{ v T }

// methods can't define new type parameters
func (b Box[T]) Map[U any](f func(T) U) Box[U] {    // ✗ compile error
	// ...
}

Methods can only use the receiver’s type parameter (T) — they cannot introduce a new type parameter. For such transformations, write a function instead.

as a function
func MapBox[T, U any](b Box[T], f func(T) U) Box[U] {
	return Box[U]{v: f(b.v)}
}

Pitfall — overly broad constraint #

over-generalization
func Process[T any](v T) {
	// any — almost nothing you can do with v
}

The wider the constraint, the fewer operations you can apply to T. Use Number for arithmetic, comparable for equality, Ordered for ordering — only as much as the operation requires.

Pitfall — sometimes interfaces are still better #

generics vs interface
// generics
func Print[T fmt.Stringer](v T) {
	fmt.Println(v.String())
}

// interface
func Print(v fmt.Stringer) {
	fmt.Println(v.String())
}

In this case, the interface is more natural. When you need dynamic dispatch — different types mixed in one slice, for example — use interfaces, not generics.

Simple guideline #

  • Data structures where type safety and performance matter → generics
  • Polymorphism is essential (mixing different types in a container) → interface
  • Want one function to accept multiple types → either; decide on readability

Pattern — Map / Filter / Reduce #

Go intentionally didn’t include them in the standard library, but generics make them natural.

Map / Filter / Reduce
func Map[T, U any](s []T, f func(T) U) []U {
	out := make([]U, len(s))
	for i, v := range s {
		out[i] = f(v)
	}
	return out
}

func Filter[T any](s []T, pred func(T) bool) []T {
	var out []T
	for _, v := range s {
		if pred(v) {
			out = append(out, v)
		}
	}
	return out
}

func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
	acc := init
	for _, v := range s {
		acc = f(acc, v)
	}
	return acc
}

If you use these often, gather them in a helper package. That said — Go’s general style is more often to write the for loop explicitly (it’s seen as more readable).

How far to abstract #

Just because generics exist does not mean every piece of code should become generic. Consider abstracting once you have seen the same shape three or more times — no earlier.

“Don’t be afraid to write a little code.” — Rob Pike

Just as Go intentionally keeps things simple, approach generics in the same spirit. Generalizing where it is not needed often hurts readability.

Wrap-up #

What we covered:

  • type parameter[T constraint] after the function/type name
  • constraint — interface that expresses method set + type set
  • ~T — includes types based on the underlying type
  • comparable — supports == comparison
  • any — no constraint (= interface{})
  • constraints.Ordered — orderable types
  • Data structures — where generics fit best
  • Methods can’t introduce new type parameters
  • When polymorphism is essential — interfaces are natural
  • Abstract when the same shape appears three or more times

In the next post (#4 reflect Package) — reflect for handling types at runtime. We’ll cover the tool you see inside serialization libraries.

X