Let’s talk about generics

Go is getting generics in 1.18, with half the devs loving and half of them hating the idea. What are they good for, really?

Lots of differently colored balls from a ball pit
Photo by Greyson Joralemon / Unsplash

Some days ago there was a long discussion about generics on one of the Discord servers that I frequent. It was rather topical: Go is getting support for generic typing in 1.18, and the question arose: What are they good for? It's a divisive issue for sure - half the developers rejoiced that generics are finally landing, while the other half either hate it, or just don't understand why Go needs generics.

I've been writing code in a couple of languages in the past decade, with both languages that have generics and some that don't, so I had my fair share of run ins with this feature set.

I don't want to go into the question of why they are being added to Go, because I haven't followed the Go developer space for quite some time, and frankly, I don't know all the reasons.

What I would like to do, however, is try to shed some light on what generics are, and what you can use them for.

🌍
If you're a veteran Go developer, I'd like to ask you to put aside your knowledge of the ecosystem and what's possible to do with already existing tools. This post is about exploring the options that generics provide, which can be useful alongside the already powerful set of capabilities that Go has.
Tools on a table. Hammer and spanner.
Photo by iMattSmart / Unsplash

What are generics?

When we're talking about generics, we usually mean that something is generic over something else. It's usually about functions being generic over some input parameters, or structs / types / classes being generic over some of their fields.

But what does that mean?

It means slightly different things in different contexts, and we'll explore each of them in a bit.

But first, let me share a useful framing device: When designing a function, or writing up a struct or a class, it's useful to think about what kind of restrictions we're putting into place for our users (other developers, in this case), because our choices can mean the difference between a good API that is easy to use, and an API you wish you weren't forced to use.

On to the main course; I categorize generics into 4 different types:

  1. Concretions
  2. Slot generics
  3. Generics over functionality
  4. Full generics

Concretions

All right, I admit, concretions aren't generics. In fact, they're exactly the opposite. I still wanted to include it, because if we look at a scale of "how generic is this thing", concretions are at the absolute start: none at all.

For example, imagine we have a web app and have created this struct to help us return results over the wire:

type Result struct {
	Status  uint
	Payload string
}

We have a struct, which has a uint and a string field. This is a concretion, because we are restricted to only using a uint and a string for those fields, respectively. In other words, as the designer of this struct, we have restricted our users to these two types.

What if we wanted to return a list of items? Well:

type ListResult struct {
	Status  uint
	Payload []string
}

Though this is better, we're still looking at a concretion: we can only return list of strings. If we wanted to return a list of, say, numbers, we'd need a NumberListResult.

Having a non-trivial application, we can see where this may lead us: A proliferation of structs that are each good for one specific purpose.

This can get considerably worse if we're a library author and want to provide some convenience functions on instantiating our numerous Result structs. We can easily explode the codebase to handle all the use cases we can think of.

And then there's the domain of use cases that we can't think of. What if our users want to have a MapStringStringResult, and we absolutely loath using maps for anything, so much so that our brain ignores the existence of maps? There's little chance that we'd have a constructor function for that use-case.

Let's look at how Generics can help us solve this problem.

Slot generics

Kid's Letter Puzzle
Photo by Ryan Wallace / Unsplash

When I say slot generics, I mean that a struct (or class) is generic over one or more of the fields it contains. I call this slot generics, because we're specifying one or more slots which can hold a value of a type that will be specified later.

To go back to our previous Result example, we could have a generic version such that:

type Result[T any] struct {
	Status  uint
	Payload T
}

This is weird syntax, but it's rather straightforward. In the above example, T is not a concrete type, it's an identifier, or placeholder for a type. You can read this type declaration the following way:

Result is a struct which has a generic type T. It has a field Status of type uint, and a field Payload, of type whatever T will be when Result is constructed.
☝️
We also see that T can be anything. More on this later.

How does one construct a generic Result?

package main

import "fmt"

type Result[P any] struct {
	Status  uint
	Payload P
}

func main() {
	//   	 👇 important bit
	result := Result[string]{
		Status:  200,
		Payload: "Hello there",
	}

	fmt.Println(result)

	//        👇 also important bit
	result2 := Result[[]string]{
		Status:  200,
		Payload: []string{"Hello there", "General Kenobi"},
	}

	fmt.Println(result2)
}

// output
{200 Hello there}
{200 [Hello there General Kenobi]}

Above we've constructed a Result struct with both a string and a []string. The syntax may seem weird again, but it's as simple as the type declaration was:

  1. Result[string] is a Result[T any] where T = string
  2. Result[[]string] is a Result[T any] where T = []string

The best part is since T can be anything, we can have nested generics (if we want, of course):

result3 := Result[Result[uint]]{
	Status: 301,
	Payload: Result[uint]{
		Status:  404,
		Payload: 401,
	},
}
fmt.Println(result3)

// output
{301 {404 401}}
Perfectly fine from the compiler's viewpoint.

But wait! Go already has the empty interface (interface{}), why do we need generics? We could just plop in interface{} and get the same results, right?

package main

import "fmt"

type IResult struct {
	Status  uint
	Payload interface{}
    //      👆 Oh look, a change!
}

func main() {
	iresult := IResult{
		Status:  200,
		Payload: "Hello there",
	}

	fmt.Println(iresult)

	iresult2 := IResult{
		Status:  200,
		Payload: []string{"Hello there", "General Kenobi"},
	}

	fmt.Println(iresult2)

	iresult3 := IResult{
		Status: 301,
		Payload: IResult{
			Status:  404,
			Payload: 404,
		},
	}
	fmt.Println(iresult3)
}
// output
{200 Hello there}
{200 [Hello there General Kenobi]}
{301 {404 404}}
Our IResult type has interface{} for Result.Payload's type

Yep, same result. Does this mean that Generics is just a more verbose syntactic sugar (or in this case.. salt?) for using interface{}?

Not exactly. Generics has two aces up its sleeve, and to show that, let's first see what happens here:

package main

import "fmt"

type IResult struct {
	Status  uint
	Payload interface{}
    //      👆 The interface version
}

func main() {
	iresult3 := IResult{
		Status: 301,
	//          👇 When instantiating, Payload gets an IResult as its value
		Payload: IResult{
			Status:  404,
			Payload: 404,
		},
	}
	fmt.Println(iresult3)

	// 				  👇 Overwriting the IResult with a string
	iresult3.Payload = "oh no"

	fmt.Println(iresult3)
}

In this instance, we're using the empty interface. Is this valid Go code? Let's ask the compiler:

$ go run prog.go
{301 {404 404}}
{301 hehe}

...yep. Alright, moving to the one with generics:

package main

import "fmt"

type Result[T any] struct {
	Status  uint
	Payload T
    //     👆 The generic version
}

func main() {
	// 				  👇 Typing it to an embedded Result
	iresult3 := Result[Result[uint]]{
		Status: 301,
		Payload: Result[uint]{
			Status:  404,
			Payload: 404,
		},
	}
	fmt.Println(iresult3)

	// 				  👇 Trying to overwrite it with a string
	iresult3.Payload = "oh no"

	fmt.Println(iresult3)
}

And the compiler says...

./prog.go:20:21: cannot use "oh no" (untyped string constant) as Result[uint] value in assignment
Go build failed.

Oh no. The compiler yelled at me. And it feels good (your mileage may vary).

What's the difference here? Well, the empty interface, per the wonderful Tour of Go:

...may hold values of any type. (Every type implements at least zero methods.)

That's the absolute opposite of using a concrete type. While concretions restricts our users (devs) to using one specific type, the empty interface allows anything, even changing the value to another type within the same struct! And that's dangerous.

We can thus change the value of a uint to a value of a string, because they are both interface{} types. The compiler is happy - we're still upholding the restrictions we've put in place.

Another drawback of using the empty interface for this is that once we put a type in, we have lost information about what that is. We can of course get it back later by doing type assertions, and, sure, it works:

package main

import "fmt"

type IResult struct {
	Status  uint
	Payload interface{}
    //     👆 hello interface
}

func main() {
	iresult3 := IResult{
		Status:  301,
		Payload: "hehe",
	}
	fmt.Println(iresult3)

	// Type assertion right here 👇
	val, ok := iresult3.Payload.(string)
	if ok {
		fmt.Println("it's a string: ", val)
	}
}
// result
{301 hehe}
it's a string:  hehe

But we've essentially delegated type checking to happen at runtime instead of at compile time, which can be the source of issues that the compiler could catch for us.

This is one of the points where generics shine: As soon as we create a Result[string], the type of Payload for that struct will always be string. And it is a string, through and through:

type Result[T any] struct {
	Status  uint
	Payload T
    //     👆 generic type
}

func main() {
	//                 👇 instantiating with a string
	result3 := Result[string]{
		Status:  200,
		Payload: "yep",
	}

	list := make([]string, 0)
    //    👇 Adding Payload to a slice of strings
	list = append(list, result3.Payload)

	fmt.Println(list)
}
// output
[yep]

We couldn't say the same for the empty interface version without doing a type assertion:

./prog.go:26:22: cannot use iresult3.Payload (variable of type interface{}) as type string in argument to append: need type assertion

The other thing that generics lets us do is that once a T is specified, anything that has that type parameter must be of that type.

For example, let's look at the following:

package main

import "fmt"

type Pair[T any] struct {
	First  T
	Second T
	//    👆 Yes, you can use the same type parameter for multiple fields
}

func main() {
	//          👇 T = string, so both First and Second are string
	p := Pair[string]{
		First:  "Ball",
		Second: "Food",
	}

	fmt.Println(p)
}

// output
{Ball Food}

Once we instantiate Pair[string], both First and Second within Pair has to be a string. We can't do p.Second = 142, because we'll be yelled at again:

./prog.go:31:13: cannot use 142 (untyped int constant) as string value in assignment

This is tremendously useful, and a huge benefit over using interface{} for both fields, as that would allow any combination of types both at instantiation and later down the struct's life.

One more thing

Go already has slot generics, all the way back from day one. I'm sure every go programmer has used them already, especially since it's part of the Tour of Go as well.

Arrays (and by extension, slices) and maps are both generic types in Go. They fit our restrictions of slot generics perfectly, although they have different syntax to what Go is getting with 1.18:

// Arrays
// primes is an array with 6 int elements
primes := [6]int{2, 3, 5, 7, 11, 13}
// names is an array with 2 string elements
names := [2]string{"Alice", "Bob"}

Arrays specify the length of its elements, as well as the type of said elements. The same holds true for maps as well:

// Maps
type Vertex struct {
	Lat, Long float64
}

// m is a map which has string keys and Vertex values
var m = map[string]Vertex{
	"Bell Labs": Vertex{
		40.68433, -74.39967,
	},
	"Google": Vertex{
		37.42202, -122.08408,
	},
}

All in all, Go 1.18 is getting the same kind of flexibility that arrays, slices, and maps already had from day one (with regards to specifying the types at instantiation).

Generic over functionality

We can call this generics just as much as we can call concretions generics. Not really.

It's interfaces. Interfaces aren't considered to be generic, but in some sense, when they are used, they kinda are.

Going back to our previous example, what if all we cared about for the payload is that it can be turned into a string?

package main

import "fmt"

type SResult struct {
	Status  uint
	Payload fmt.Stringer
    //      👆 A wild interface appears!
}

type Msg struct {
	message string
}

//   👇 Msg implements fmt.Stringer because of this.
func (m Msg) String() string {
	return m.message
}

func main() {
	result := SResult{
		Status:  500,
		Payload: Msg{message: "Internal Server Error"},
	}

	fmt.Println("result is:", result)
}
// output
result is: {500 Internal Server Error}

Naturally, the above is a silly example, but it shows that interfaces can also be used for typing our structs. In that case, we're essentially telling the compiler (and our users [devs]), that we don't care what's in there, as long as it implements the Stringer interface.

That's a form of generics, in my book. The SResult struct above is generic over the Stringer interface. From a restrictions standpoint, we've restricted the allowed types to only those that fulfill the Stringer contract. The compiler will check this for us, and the compiler will yell at people to provide what is required if they want the code to compile.

I don't really want to dwell too much on this - Go has this feature, and I believe it's well understood by pretty much all the devs. Nevertheless I felt that it was an important point to make, because...

Full generics

What a segue

Remember that 13 Page-Ups ago, I mentioned that

We also see that T can be anything. More on this later.
🕑
Later is now

The best part about generics is how easy it is to further restrict what our code can do. For example, to get the same functionality as the SResult above (that Payload must be a type that implements fmt.Stringer), we can do:

package main

import "fmt"

//          👇 This is the only part that's different from the above example
type SResult[T fmt.Stringer] struct {
	Status  uint
	Payload T
}

type Msg struct {
	message string
}

func (m Msg) String() string {
	return m.message
}

func main() {
	//        👇 Okay, and this as well
	result := SResult[Msg]{
		Status:  500,
		Payload: Msg{message: "Internal Server Error"},
	}

	fmt.Println("result is:", result)
}
// output
result is: {500 Internal Server Error}

It's the same thing, we just moved some characters around. Some languages allow for a bit more powerful features when it comes to generics, for example, Rust can require that a type fulfills more than one trait (which for our purposes, are kinda like interfaces)

use std::cmp::Ord;
use std::fmt::Display;

//            👇 T must implement both Display (~fmt.Stringer) and Ord (can be ordered)
struct SResult<T: Display + Ord> {
    status: usize,
    payload: T,
}

fn main() {
    let r = SResult {
        status: 200,
        payload: "Success!",
        //       👆 &str implements both Display and Ord, so can be used here
    };
}

Unfortunately, at the moment Go doesn't seem to allow more complex restrictions, such as specifying that the type must implement more than one interfaces (or I haven't found how to do it yet). I'm hopeful it'll be possible though, as this can be rather useful.

What it does allow, is creating type unions:

package main

import (
	"fmt"
)

//  👇 Number is either a uint, an int, or a float64
type Number interface {
	uint | int | float64
}

type SResult[T Number] struct {
	Status  uint
	Payload T
}

func main() {
	//                👇 in this case, we type it as an int
	result1 := SResult[int]{
		Status:  500,
		Payload: -10,
	}

	fmt.Println("result1 is:", result1)

	//                👇 in this case, we type it as a float64
	result2 := SResult[float64]{
		Status:  500,
		Payload: 10.0,
	}

	fmt.Println("result2 is:", result2)

	//                👇 in this case, we type it as a uint
	result3 := SResult[uint]{
		Status:  500,
		Payload: uint(10),
	}

	fmt.Println("result3 is:", result3)
}
// output
result1 is: {500 -10}
result2 is: {500 10}
result3 is: {500 10}

..but as soon as we try to use a type that is not one of the ones we specified:

result1 := SResult[string]{
	Status:  500,
	Payload: "oh no",
}
fmt.Println("result1 is:", result1)

// output
./prog.go:17:21: string does not implement Number

This is important - the compiler is yelling at us again. But how is this useful? Well, one thing I haven't talked about yet is that functions can be generic as well:

package main

import (
	"fmt"
)

type Number interface {
	uint | int | float64
}

//      👇 Same as with structs; Add is generic over T, which has to be of type Number
func Add[T Number](a, b T) T {
	return a + b
}

func main() {
	//										           👇int, int
	fmt.Printf("result: %[1]d, result type: %[1]T\n", Add(-10, 20))
    //										           👇uint, uint
	fmt.Printf("result: %[1]d, result type: %[1]T\n", Add(uint(10), 20))
    //										           👇float64, float64
	fmt.Printf("result: %[1]f, result type: %[1]T\n", Add(10.0, 20.0))
}
// output
result: 10, result type: int
result: 30, result type: uint
result: 30.000000, result type: float64
Technically, they are not "Number types", but types that implement the Number interface

Two powerful mechanisms are at work here:

Type restriction on the arguments

Add is generic over its input arguments. It requires that both a, b, and its return type is a Number. In addition, it is also required that a, b, and the return type must be of the same type. For example, the following causes a compile error, even though both uint and int satisfy the Number interface:

Add(uint(10), int(39))
// output:
./prog.go:20:16: type int of int(39) does not match inferred type uint for T

And that's huge. It allows us to move a myriad of type checking from runtime to compile time, as well as writing code that can leverage such restrictions, just like the silly little example for Add above.

Type inference

Have you noticed that Go's type inference leapt into action? There's three things we didn't have to do:

  1. Specify what types we're calling Add with. The compiler was able to deduce that since we're invoking it with a valid type, we don't have to explicitly state the type like with the struct versions (Yes, I did have to add uint, but that was to make sure we're using that version of the function).
  2. In case where we had to cast one of the arguments (uint(10)), we didn't have to do the same for the second. The compiler figured out that since Add requires that both its arguments have to be T, the second argument must also be a uint, so it did the "cast" for us.
  3. The return value is typed accordingly as well, as can be seen from the output, so that was inferred for us as well. No need for type assertions 🎉

Some closing thoughts

I hope by this point you can see just how powerful generics can be, if used in the right places. Not all places are good for generics, mind you, sometimes all we need is a sturdy type and that's it. Other times we can use interfaces, but there are some situations where using generics is just a better approach altogether.

Go's map does this as well, and it even uses some restrictions. If we read through the post about maps on the official Go language blog, we'll see the following section:

A Go map type looks like this:

map[KeyType]ValueType

where KeyType may be any type that is comparable (more on this later), and ValueType may be any type at all, including another map!

...later on (emphasis mine):

As mentioned earlier, map keys may be of any type that is comparable. The language spec defines this precisely, but in short, comparable types are boolean, numeric, string, pointer, channel, and interface types, and structs or arrays that contain only those types. Notably absent from the list are slices, maps, and functions; these types cannot be compared using ==, and may not be used as map keys.

This sounds a lot like generics to me. You can use anything for the KeyType as long as those things can be compared to one another, and everything goes for the values.

I'd argue Go's maps are powerful. I'd argue that the doors generics open are also powerful.

Lastly, one thing I didn't mention, but is an important notion: You can have as many generic types on a struct / function as you want, and have different sets of restrictions for each of them. This is fair game:

type Tuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any] struct {
	First  T1
	Second T2
	Third  T3
	Fourth T4
	Fifth  T5
	Sixth  T6
}

Of course, you'd probably want to stop at one or two generic types, anything above that can become hard to understand. As with any tools, one can go overboard and abuse it, generics isn't immune to that.

Code responsibly, and use the right tools for the job.

Until next time 👋