Trying Out Generics in Go

Updated: Sunday, December 26, 2021

In case you’ve been living under a rock these past couple of years, you might not know that Go is getting generics in version 1.18. If you were aware of this, you still may not have been giving it much attention like myself.

The other night I saw this tweet from the Go team which gave me the motivation to try using generics myself:

This post aims to describe my initial experience converting my markphelps/optional library from using code generation to using generics instead. Hopefully, after reading this post you’ll have a better understanding of what generics can do for you and what they can’t.

Some Background

My first response when the plan to add generics was announced was “meh”. In my 5+ years working in Go, I can probably count on one hand the number of times that I felt like I really needed generics. Most of the code I write in my day job is very specific to the domain and doesn’t fit the use case that generics aim to fill. That being said, I still wanted to play with the shiny new thing and at least get a handle on the syntax before 1.18 is released.

Level Up With Go

Thanks for reading! While you're here, sign up to receive a free sample chapter of my upcoming guide: Level Up With Go.


No spam. I promise.

I quickly realized that I already had an existing library that would likely benefit from being updated to use generics, markphelps/optional.

If you want to read more about why and how markphelps/optional was created, check out my previous post

The tl;dr of it is that I used code generation and Go’s text/template library to support both primitive and custom types. This is what I wanted to try to replace with generics.

Generics to the Rescue

Before jumping into code I took another quick read-through of the very detailed proposal to refresh my knowledge of the basic syntax.

The basic syntax is this:

// 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) {
   // same as above
}

Where the [T any] after the function name specifies that T can be any type (any is basically interface{}). Seems simple enough, so I created a new branch and got to deleting some code.

I no longer needed any of the pre-generated optional types like byte, string, int, etc that are present in the main branch.

This took the library from:

✦ ➜ ls
CONTRIBUTING.md README.md       cmd             error.go        float64.go      go.sum          int32.go        int_test.go     string_test.go  uint32.go       uintptr.go
LICENSE.md      bool.go         complex128.go   example_test.go generate.go     int.go          int64.go        rune.go         uint.go         uint64.go
Makefile        byte.go         complex64.go    float32.go      go.mod          int16.go        int8.go         string.go       uint16.go       uint8.go

To:

✦ ➜ ls
CONTRIBUTING.md  LICENSE.md       Makefile         README.md        example_test.go  go.mod           go.sum           optional.go      optional_test.go

Much better.

I created optional.go to contain the generic code and got to writing. I should say I got to copying, as I simply inserted the code from one of the previously generated files and replaced the type names.

tip: I find that it's much easier to extract/replace previously typed code with generic code than it is to write the code generically from scratch.

The code now looks like this:

// Optional is an optional T.
type Optional[T any] struct {
   value *T
}

// New creates an optional T from a T.
func New[T any](v T) Optional[T] {
   o := Optional[T]{value: &v}
   return o
}

// Set sets the value.
func (o *Optional[T]) Set(v T) {
   o.value = &v
}

Now the user of my library could write code like this:

o := New(42)

v, err := o.Get()
if err != nil {
   return err
}

I like the type inference here as well so that users don’t have to write something like:

o := New[int](42)

You do have to add the type though when you just want to declare a new variable like:

var o optional.Optional[int]

This is not as nice looking, but I understand the necessity.

I’ll have to admit that overall the syntax was and still is a bit jarring to me. What are all these brackets doing here?! Over time I’m sure that I will get used to it, but I did notice that it took me longer to grok this code than normal.

Trying Out Constraints

Generics wouldn’t be as useful as they are if there wasn’t a way to guard which types can actually use your generic code. The classic example is calling the String() method on a type T any like:

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

This is invalid and will not compile because T being an any or interface{} doesn’t guarantee that it will have a String() method.

The fix here is to use constraints, which define which types are allowed to be used. Constraints are interfaces that can also contain type sets such as:

// SignedInteger is a constraint that matches any signed integer type.
type SignedInteger interface {
   ~int | ~int8 | ~int16 | ~int32 | ~int64
}

For the above case, we can get by with using the existing fmt.Stringer interface like:

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

At first, I tried to use constraints in my rewrite in order to not re-introduce this previously fixed bug.

Not all types can marshall/unmarshall to and from JSON per this comment in the encoding/json source.

In the pre-generics version I fixed this by simply not generating code for complex types such as complex64/complex128, but this wouldn’t work in the generic version.

Initially, I thought about using a constraint like:

type marshallable interface {
   constraints.Integer | constraints.Float | ~string | json.Marshaler
}

This would work for most use cases and prevent anyone from using the library with complex types such as complex64, but what about if they weren’t using JSON at all and just wanted to do something like creating an optional of a func:

o := New(func()int{
   return 42
})

Using the above constraint when defining Optional would prevent this invocation because of the marshallable constraint.

In the end, I decided to relax the constraints and just use any when defining T. This would allow the user to write the above, however, if they were to call json.Marshal(o) with o being a func, they would get the error:

json: error calling MarshalJSON for type optional.Optional[func() int]: json: unsupported type: func()

I think this is ok from a library perspective to return the error at runtime instead of guarding against it at compile-time, since after all, that’s why json.Marshal can return this error in the first place.

A Few Tips

Before wrapping up, I’d like to provide a few tips for those getting started with 1.18 and generics:

  1. As stated earlier, it’s usually easier to replace typed code with generics than to write it using generics from the beginning. Start with a sample implementation using any type, then replace that type with generics.
  2. Read the proposal/spec! It is pretty dense, but at least skim through it before starting to write code with generics. It helped me out a few times in this experiment with examples and explanations when I got stuck.
  3. Constraints can be tricky. They are often necessary to prevent compile-time errors, but can also introduce runtime errors. Thankfully the proposal authors thought of this and provided some suggestions on dealing with this problem.
  4. Remember, it’s still in beta, so I wouldn’t go updating your production code to use generics just yet 😉

In Closing

I ❤️ that I was able to delete 95% of my code because of generics.

I think that generics will be very beneficial to maintainers who create libraries for things like my own as well as for searching, sorting, transformations, and the like. I can also see some them being extremely helpful for creating well-tested libraries around the various concurrency patterns that are sometimes tricky to get right. I’m also personally excited to never have to write min/max type functions for the various primitive types ever again.

I’m not sure that most Go developers will be using generics daily, but it’s nice to know that they exist if we need them.

What do you think about the addition of generics to Go? Do you see yourself using them regularly or just occasionally? Are you looking forward to generics solving certain code duplication in your own code? Reach out to me on Twitter and let me know!

fyi: If you want to checkout the 1.18 branch of markphelps/optional and see the code it’s available here. I’ll likely create a new release once 1.18 is released.

Like this post? Do me a favor and share it!