A Comprehensive Guide to Generics in Go

Steven Ellis
ITNEXT
Published in
10 min readJan 23, 2023

--

Go is a statically typed language. This means that the types of variables and parameters are checked at compile time. Built-in Go types such as maps, slices, channels, as well built-in Go functions such as len & make, are able to accept and return values of different types, but before 1.18, user-defined types and functions could not.

This meant that in Go, if for example I created a binary tree for int:

type IntTree struct {
left, right *IntTree
value int
}
func (t * IntTree) Lookup(x int) *IntTree { … }

…and then wanted to create a binary tree for strings or floats, and I needed type safety, I would need to write a custom tree for each type. This is verbose, error-prone and flagrantly violates the don’t repeat yourself principle.

If, on the other hand, I just changed my binary tree function to just accept parameters of type interface{} (the same as accepting any types), I would be bypassing the safety net of compile-time type-checking, one of Go’s primary advantages. There are additional pitfalls to using parameters of type interface{}. In Go one can’t create a new instance of a variable that’s specified by interface. Go also doesn’t provide a way to process a slice of any type, meaning one can’t assign a []string or []int to a variable of interface{}.

The end result was that before version 1.18 and Generics, common algorithms — such as map, reduce, filter on slices —usually had to be re-implemented for each type. This was often frustrating for software developers, and inevitably became one of the primary criticisms of the Go language.

What Go developers needed was the ability to write functions or structs where the specific type of a parameter or variable could be specified when used, whilst being type-safe when compiled.

As an example, consider the following two functions that sum together the values of map[string]int64 and map[string]float64 respectively:

// SumInts adds together the values of m.
func SumInts(m map[string]int64) int64 {
var s int64
for _, v := range m {
s += v
}
return s
}

// SumFloats adds together the values of m.
func SumFloats(m map[string]float64) float64 {
var s float64
for _, v := range m {
s += v
}
return s
}

Wouldn’t it be cool if one could write a single type-safe function that efficiently sums the values of any numeric map, instead of rewriting a new function for each mapped type?

…or the following two functions that double int and int16 values:

func DoubleInt(value int) int {
return value * 2
}

func DoubleInt16(value int16) int16 {
return value * 2
}

Again, wouldn’t it be cool to be able to write a single type-safe function that doubles any numeric type?

Why the long wait?

If the case for generics is so strong, why did it take the Go development team over 10 years to add the feature to the language? Not surprisingly, it’s because the problem was a difficult one to solve. Go emphasizes a fast compiler, clear & readable code and good execution times, and the various proposed implementations often compromised one or more of these tenants to an unacceptable degree.

As is the beauty of open-source software, however, collective problem-solving eventually coalesced on an implementation that was deemed acceptable by the Go team and community. The result is a solution that is essentially Go in nature, fast and efficient, yet flexible enough to meet the specification requirements.

Generics in Go —a summary

  • Since version 1.18, the Go language has been extended to allow adding explicitly-defined structural constraints — called type parameters — to function declarations & type declarations.
func F[T any](p T) { … }

type M[T any] []
  • The type parameter list [T any] uses square brackets, but otherwise looks like a regular parameter list. These type parameters can then be used by a function’s regular parameters and in the function body.
  • Generic code expects the type arguments to meet certain requirements, referred to as constraints. Each type parameter must have a constraint defined:
func F[T Constraint](p T) { … }
  • These constraints are nothing more than interface types.
  • Type parameter constraints can restrict the set of type arguments in one of three ways:
  • 1. an arbitrary type T restricts to that type
func F[T MyConstraint](p T) { … }
  • 2. an approximation element ~T restricts to all types whose underlying type is T
func F[T ~int] (p T) { … }
  • 3. a union element T1 | T2 | … restricts to any of the listed elements
func F[T ~int | ~string | ~float64] (p T) { … }
  • When generic functions and types use operators against defined type parameters, those operators must satisfy the interface that is defined by the parameter’s constraint. We’ll see examples of this later.
  • The design is fully backward compatible with Go 1.

Type parameters explained

Type parameters in generic functions & types are similar to, but not the same as, ordinary non-type parameters. They are defined through an additional parameter list that appears before the regular parameters, using square brackets rather than parentheses. Once defined, they can be listed alongside regular parameters, and in the body of the generic function or type.

Just as regular parameters have types, type parameters have meta-types, also known as constraints.

// Print prints the elements of any slice.
// Print has a type parameter T and has a single (non-type)
// parameter s, which is a slice of that type parameter.
func Print[T any](s []T) {
for _, v := range s {
fmt.Println(v)
}
}

This says that within the function Print the identifier T is a type parameter, a type that is currently unknown but that will be known when the function is called. As shown above, the any constraint permits any type as a type argument and only permits the function to use the operations permitted for any type. The interface type for any is the empty interface: interface{}. In fact, in generics the any keyword is just syntactic sugar for interface{}. So we could write the Print example as

func Print[T interface{}](s []T) {
// same as above
}

The type parameter (T) can also used when defining the function’s parameter types — the parameter s thus is defined as a slice of T. It may also be used as a type within the body of the function.

It is considered idiomatic (i.e. good practice) to name type parameters with a single capitalized letter, for example T or S.

Since Print has a type parameter defined, all invocations of Print must now provide a type argument. Type arguments are passed like type parameters are declared: as a preceding list of arguments using square brackets.

// Call Print with a []int.
// Print has a type parameter T, and we want to pass a []int,
// so we pass a type argument of int by writing Print[int].
// The function Print[int] expects a []int as an argument.
Print[int]([]int{1, 2, 3})

// This will print:
// 1
// 2
// 3

In the above example the type argument [int] is given to the Print function, to explicitly indicate that we’re invoking the generic function with a slice of ints.

Type parameter constraints explained

Below is a function that converts a slice of any type into a []string by calling a String method on each element.

// This function is INVALID.
func Stringify[T any](s []T) (ret []string) {
for _, v := range s {
ret = append(ret, v.String()) // INVALID
}
return ret
}

While this looks OK at first glance, in this example v has type T, and T can be any type.

This means that T MUST now have a String() method for the call to v.String() to be valid.

As we saw above, type arguments meet certain requirements, called constraints. The constraints set limits on both the type arguments passed by the caller and the code in the generic function.

Constraints are simply interface types. In Go, any defined types that implement the same methods as an interface can then be assigned to a variable of the interface. This means that satisfying a constraint means implementing the interface type.

The caller may thus only pass type arguments that satisfy the constraint, and the type arguments must implement any methods defined by the constraint.

Back to our example above, we see the constraint is any. The only operations that the generic function can use with values of that type parameter are those operations that are permitted for values of any type.

So does the any type does have a String() operation? As it turns out, no it does not. For our Stringify example to compile, we cannot use any as the type constraint.

For the Stringify example to work, we need an interface type with a String() method that takes no arguments and returns a value of type string.

// Stringer is a type constraint that requires the type argument to have
// a String method and permits the generic function to call String().
// The String method should return a string representation of the value.
type Stringer interface {
String() string
}

// Stringify calls the String method on each element of s, and returns the results. The single type parameter T is followed by the constraint that applies to T, in this case Stringer.
func Stringify[T Stringer](s []T) (ret []string) {
for _, v := range s {
ret = append(ret, v.String())
}
return ret
}

…and an invocation example for an int type:

import (
"fmt"
"strconv"
)


type myint int

func (i myint) String() string {
return strconv.Itoa(int(i))
}

func main() {
x := []myint{myint(1), myint(2), myint(3)}
Stringify(x)
fmt.Println(x)
// Prints "[1 2 3]"
}

Note that, just as each ordinary parameter may have its own type, each type parameter may have its own constraint:

// Stringer is a type constraint that requires a String method.
// The String method should return a string representation of the value.
type Stringer interface {
String() string
}
// Plusser is a type constraint that requires a Plus method.
// The Plus method is expected to add the argument to an internal
// string and return the result.
type Plusser interface {
Plus(string) string
}
// ConcatTo takes a slice of elements with a String method and a slice
// of elements with a Plus method. The slices should have the same
// number of elements. This will convert each element of s to a string,
// pass it to the Plus method of the corresponding element of p,
// and return a slice of the resulting strings.
func ConcatTo[S Stringer, P Plusser](s []S, p []P) []string {
r := make([]string, len(s))
for i, v := range s {
r[i] = p[i].Plus(v.String())
}
return r
}

Go’s predefined constraints & type inference

Consider the following generic summing function that solves our non-generic summing problem earlier identified:

// SumIntsOrFloats sums the values of map m. 
// It supports both int64 and float64 as types for map values.
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}

Note the constraint of the K type parameter: comparable. Since many generic functions involve comparing and looping through maps & slices, Go has made available helper libraries for commonly used constraint types.

The standard library’s comparable allows any type whose values may be used as an operand of the comparison operators == and !=. Since Go requires map keys to be comparable, declaring K as comparable is necessary so you can use K as the key in the map variable.

Secondly, note the pipe-delimited list of constraint types for the V type parameter: int64 | float64. Using | specifies a union of the two types, meaning that this constraint allows either an int64 or a float64. Either type will be permitted by the compiler as an argument in the calling code.

Note the func’s argument — map[K]V. We know map[K]V is a valid map type because K is a comparable type. If we hadn’t declared K comparable, the compiler would reject the reference to map[K]V .

Invoking the above function is done as follows:

// Initialize a map for the integer values
ints := map[string]int64{
"first": 34,
"second": 12,
}

// Initialize a map for the float values
floats := map[string]float64{
"first": 35.98,
"second": 26.99,
}

fmt.Printf("Generic Sums: %v and %v\n",
SumIntsOrFloats[string, int64](ints),
SumIntsOrFloats[string, float64](floats))

Note that we specify type arguments — the type names in square brackets — to be clear about the types that should replace type parameters in the function being invoked.

Often, the Go compiler can infer the types you want to use, from the function’s arguments. This is called type inference, and allows you to invoke the generic functions in a more simplified fashion (omitting the type arguments)

fmt.Printf("Generic Sums, type parameters inferred: %v and %v\n",
SumIntsOrFloats(ints),
SumIntsOrFloats(floats))

In Conclusion and parting thoughts

Go generics are a simple, elegant and powerful addition to the Go language, enabling developers to use simple abstract patterns in a type-safe way. Over the coming years, as generics gain mass adoption, they will result in even more efficient and effective Go coding. Nonetheless, Go coders should be prudent when choosing to use generics. To quote Ian Lance Taylor:

“If you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider using a type parameter. Another way to say this, is you should avoid using type parameters until you notice you’re about to write the exact same code multiple times.”

Generic code examples

To end, here are some useful examples of generics in Go:

Generic numeric functions

Generic slice functions

Generic map functions

Generic implementation of a Set

--

--

Computer scientist, currently also pursuing a Masters in Data Science.