Skip to article frontmatterSkip to article content

Go Generics

Intro

Go 1.18 introduced generics support. From that version, functions can take generic (parametrically polymorphic) types, and interfaces got the ability to describe union types.

min(x, y) for go 1.18

go.mod
module main

go 1.18

require golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c
main.go
package main

import (
	"golang.org/x/exp/constraints"
	"fmt"
)

func min[T constraints.Ordered](x, y T) T {
	if x < y {
		return x
	}

	return y
}

func main() {
	fmt.Println(min[int](3, 2))
	// => 2

	fmt.Println(min[string]("xyz", "klm"))
	// => klm

	// '_' is 95, '0' is 48 (like in C/ASCII)
	fmt.Println(min[rune]('_', '0'))
	// => 48
}

Observe that we pass the concrete type when calling min, like min[int](...), min[string](...) and min[rune](...) in our example. Providing that concrete type while calling the function is called instantiation.

Instantiation happens in two steps:

And if step two fails, instantiation itself fails.

Go 1.21 cmp.Ordered

From Go 1.21, cmp.Ordered is the type we should use:

import "cmp"

func min[T cmp.Ordered](x, y T) T {
	if x < y {
		return x
	}

	return y
}

The instantiation and usage is the same as the previous example with constraints.Ordered.

Instantiation examples

It is possible to instantiate the generic function with a concrete type (without actually calling it). The instantiation produces a non-generic function, which can be assigned to a variable for later use.

var minInt8 = min[int8]
var minStr = min[string]

func main() {
	fmt.Println(minInt8(7, 2))
	// => 2

	fmt.Println(minStr("abc", "KLM"))
	// KLM
}

Generic structs

Example with a binary tree

type Tree[T any] struct {
	left, right *Tree[T]
	data        T
}

Then we could implement methods on Tree:

func (t *Tree[T]) Find(v T) *Tree[T] {
	// Logic to find v.
}

And create concrete-typed instances from the generic Tree[T]. That is, we can instantiate T to any concrete type that (in our example), satisfies the cmp.Ordered interface:

var sTree Tree[string]
var iTree Tree[int64]

Type sets and type constraints

An ordinary parameter list has a type for each parameter. This type defines a set of values that inhabit that type (all possible strings, or all possible integer numbers, etc.)

func min(x, y int64) int64 {
	// ...
}

In the min() function above, int64 is the type for both x and y, it it means that both x and y can take any of the values that inhabit the int64 type.

Compare with this:

func min[T cmp.Ordered](x, y T) T {
	// ...
}

In this case, the type parameter list also has a type for each parameter. It is called a type constraint, and it defines a set of types. It is called type constraint because it constrains the types that it accepts. In this example, the cmp.Ordered (or constraints.Ordered in Go 1.18 and 1.19) type constraint means that T` can be any type that allows its values to be ordered in some way, and therefore, be compared in terms of which value domes first or after the other value in some sense.

It means integers, strings, floats satisfy cmp.Ordered and therefore are valid values to be passed to min(), but types like booleans or struct do not satisfy cmp.Ordered, and therefore would not be valid input values to min().

Type constraints are interfaces

An interface defines a set of methods. Any type that implements that set of methods implements that interface.

Another way to look at it is that an interface defines a set of types, which is where the following syntax in Go comes from:

type MyType interface {
	T1 | T2 | Tₙ
}

Operators like < or > are not methods. So how come type constraints are interfaces?

type Ordered interface {
	Integer | Float | ~string
}

The vertical bar expresses an union of the types. Integer Float are interfaces themselves.

The tilde “~” is a new token introduced in Go 1.18. In short, it means ~T the set of all types with underlying type T. In our example, ~string means all types that have the underlying string type.

A type constraint has two functions:

Constraint literals (inline constraints)

Take this type constraint (with inline interfaces):

[S interface{ ~[]E }, E interface{}]

Go 1.8 added some syntax sugar so interface{ ~[]E } can be shortened to simply ~[]E, so the type constraint can be written as:

[S ~[]E, E interface{}]

Also, the empty interface interface{} got an alias any, the type constraint can be even written like this:

[S ~[]E, any]

Scale() function example

Non-working implementation

Let’s consider this piece of code:

package main

import (
	"fmt"
	"golang.org/x/exp/constraints"
)

// scale takes a slice of Integer and returns a new slice with each
// integer multiplied by k.
func scale[E constraints.Integer](s []E, k E) []E {
	scaled := make([]E, len(s))

	for i, v := range s {
		scaled[i] = v * k
	}

	return scaled
}

// Point represents the coordinates of a point.
type Point []int32

// Str returns a stringified version Point p.
func (p Point) Str() string {
	var s string

	for _, v := range p {
		s += string(v) + " "
	}

	return s
}

func main() {
	xs := Point{2, 3, 4}

	scaledXs := scale(xs, 2)

	// ERROR: Doesn't compile.
	fmt.Printf("%s\n", scaledXs.Str())
	// ~ scaledXs.Str undefined (type []int32 has no field or method Str)
}

The problem with this implementation is that scale() returns a []E, where E is the element type of the argument slice.

When we call scale() with a value of type Point, whose underlying type is []int32, we get back a value of type []int32, not a value of type Point. The problem here is that Point has the method Str(), but `[]int32`` does not, thus the error.

Working implementation

The fix is simple: we simply use more appropriate types and things work.

- func scale[E constraints.Integer](s []E, k E) []E {
+ func scale[S ~[]E, E constraints.Integer](s S, k E) S {
-   scaled := make([]E, len(s))
+   scaled := make(S, len(s))

  for i, v := range s {
    scaled[i] = v * k
  }

  return scaled
}

We introduced a new type constraint S ~[]E (which is the type of the argument), so that the underlying type of its argument must be a slice of some element of type E.

With this change, the first argument of the function is of type S, rather than []E.

But now if we call scale(p, k), and p is of type Point, then the return type will also be of type Point, and Point does have a method Str()`.

And with those changes we have a proper, working implementation of scale() as the types now work as we actually need them to work.

Type inference

This is how we can call scale():

xs := Point{2, 3, 4}

scaledXs := scale[Point, int32](xs, 2)

fmt.Printf("%s\n", scaledXs.Str())

We pass Point as the type constraint for S (xs parameter), and int32 for the type constraint for E (2 parameter). But we can also call it without providing the type constraints explicitly, and the type checker will be able to correctly infer the types from the parameters passed.

xs := Point{2, 3, 4}
scale(xs, 2)

In this case, the type checker infers that the type constraint for S is Point (and ``Point’s underlying type is int32). The type constraint for 2isint32`.

All the type arguments are successfully inferred, so the function can be instantiated and called without explicit type arguments provided at the call site.

References