Migrating a large Go codebase to support generics

Reilly Watson
Inside League
Published in
6 min readSep 12, 2022
A big pile of Lego minifig heads
Photo by Carson Arias on Unsplash

Since we started building in 2014, League’s backend has been almost entirely developed using Go. Over this time, we’ve built up a large-ish codebase — our main repository currently holds around 1.4 million lines of Go code, excluding third-party dependencies!

Go 1.18 introduced the largest language change since its initial release: the ability to write code that can operate on multiple types (i.e. generics).

Before this release, if I wanted to write a function like Max(x,y), I had a few options:

  1. Set some specific type for x and y and the return value. Maybe float64? This is what the standard library does. Do a bunch of typecasting everywhere that I call it with any other type.
  2. Write a bunch of different versions of Max: MaxInt(), MaxFloat(), MaxMyIntAlias(), etc.
  3. Write some code generation thing that does #2 for me, for whatever types I have.

None of those options are great: typecasting is awkward to read and can introduce precision bugs, writing “N” different versions means writing and maintaining a bunch of nearly-identical functions, and code generation introduces a bunch of extra tooling complexity that complicates the build process. This has been a major complaint that’s surfaced in Go’s annual developer survey year after year (see the 2016, 2017, 2018, 2019, 2020, and 2021 surveys, searching “generics” in each will show the relevant data).

Anyway, after years of various proposed implementations, this is a real thing you can do now! Instead of any of those three bad options, I can now write this and it’ll work for all the different number types:

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

Neat!

Migrating to Go 1.18

Go’s compatibility promise has historically made updating our Go version quite painless! We started using Go with version 1.3 and have been able to keep up to date on each new major version. Out of those 14 updates (from 1.3 up to 1.17), nearly all of them have essentially been as simple as sed s/go1.X/go1.Y/g.

This latest version is a bit different in that respect. Not because of anything in the standard library or toolchain, but because of CI (Continuous Integration) tooling. We run a large suite of static analysis tools on every pull request opened in our backend, including things like staticcheck, go vet, errcheck, gosec, and a couple dozen analyzers that are specific to our codebase (see the “Writing your own static analyzers” section of this post for an example). Each of those tools does some sort of parsing of Go source code to do its checking, and since there’s now this weird new square bracket stuff that might show up in the code, each of those tools needs to be updated to understand that.

This meant we needed to patch some of our tooling dependencies and disable a few checks. In particular, a few of our checks relied on some Single Static Assignment support that’s still in-progress, so they needed to be temporarily disabled. We also needed to patch golang.org/x/tools to fix some issues with parsing generics code (see here).

After we got all that sorted out, we were ready to go!

Where we use generics

There are a couple categories of things we were doing that generics are well-suited for — type conversion and event aggregation.

Type conversion

We had quite a few places where we needed to check if an element is in a list. We had a helper function for this for strings, StringInList(). But if your thing was a string-backed type (type UserId string; var userIds []UserId or what have you), this helper function couldn’t be called. So we had another function ToStringList() that took an empty interface (which had to be a slice of string-backed things), and converted it to a []string using reflection. StringInList(ToStringList(userIds), string(someId))was something we had in quite a few places in our code. Not great performance-wise (all this converting and reflecting wasn’t free!), or safety-wise (the compiler wouldn’t tell you if you passed the wrong thing to ToStringList()).

Now instead of any of that, we can call slices.Contains(userIds, someId) and it’ll do all the right things, including erroring out when “userIds” and “someId” are different types.

We also wrote a generic version of ToStringList(), because there are some times we still need to do that kind of conversion.

Before (reflection-based):

// ToStringList converts a slice of string-like things (ie where the underlying type is a string) into a []string.
func ToStringList(list interface{}) []string {
if list == nil {
return nil
}
val := reflect.ValueOf(list)
if val.Kind() != reflect.Slice {
return nil
}
result := make([]string, 0, val.Len())
for i := 0; i < val.Len(); i++ {
ival := val.Index(i)
if ival.Kind() == reflect.String {
result = append(result, ival.String())
}
}
return result
}

After (generics):

// ToStringList converts a slice of string-like things (ie where the underlying type is a string) into a []string.
func ToStringList[T ~string](list []T) []string {
if list == nil {
return nil
}
result := make([]string, len(list))
for i, v := range list {
result[i] = string(v)
}
return result
}

Much better! That earlier version had weird and surprising behavior if you passed it anything other than a slice of string-like things, whereas the new version tells you at compile time that you’re doing something wrong.

Event aggregation

Many of the updates to our data model are done using an event aggregation model, wherein you construct an event representing your change and an aggregator function that applies that change to your model. This lets us maintain a nice audit trail of changes to our system and helps manage concurrency issues for changes.

Previously, our aggregation functions took an interface “Aggregateable”, which each aggregator then had to cast to the type they cared about. So there was a lot of code like this:

func aggregatePrice(ctx context.Context, id model.BenefitPriceId, eventType event_model.EventType, info map[string]any, aggregator func(context.Context, *model.EmployerBenefitPrice, map[string]any) (bool, error)) (*model.EmployerBenefitPrice, error) {
benefitPrice := &model.EmployerBenefitPrice{
Id: id,
}
result, err := aggregate.Aggregate(ctx, benefitPrice, entity.EntityId(id), info, aggregate.EventType(eventType), func(ctx context.Context, agg aggregate.Aggregateable, msg map[string]any) (bool, error) {
return aggregator(ctx, agg.(*model.EmployerBenefitPrice), msg)
})
if result != nil {
return result.(*model.EmployerBenefitPrice), err
}
return nil, err
}

Notice the casting of agg and result, and how we need this whole extra anonymous function just to transform things into a reasonable typed interface for callers.

Instead, now Aggregate() is a generic function. The function signature is a bit hairy:

func Aggregate[T Aggregateable](ctx context.Context, agg T, entityId entity.EntityId, info map[string]any, eventType EventType, aggFn func(context.Context, T, map[string]any) (bool, error)) (T, error)

The key thing here is that it takes any type that implements the Aggregateable interface, but it uses the exact type passed when returning results and when defining the aggregator function. So callers get a much nicer experience, the above aggregatePrice() function now becomes:

func aggregatePrice(ctx context.Context, id model.BenefitPriceId, eventType event_model.EventType, info map[string]any, aggregator func(context.Context, *model.EmployerBenefitPrice, map[string]any) (bool, error)) (*model.EmployerBenefitPrice, error) {
benefitPrice := &model.EmployerBenefitPrice{
Id: id,
}
return aggregate.Aggregate(ctx, benefitPrice, entity.EntityId(id), info, aggregate.EventType(eventType), aggregator)
}

No more anonymous function, no casting, everything just works nicely.

Note that the Go team’s blog post When to Use Generics says “don’t replace interface types with type parameters”, but this case (where you’re passing an interface, but also a function that operates on the same concrete type) feels like an exception to that guideline.

What’s next?

This is just our first baby steps into adapting our system to use more generic code. In the future we expect to start introducing more kinds of typesafe containers (an LRU cache and concurrent map are particularly interesting candidates), generic libraries for easier handling of different concurrency patterns, and possibly higher-level functional programming operations (map/reduce/filter etc, although the usability of these may depend on the outcome of this proposal).

So far we’ve been quite happy with things though! We had some concerns that generics would complicate a bunch of code, but so far these have been unfounded; people mostly just write the same code as before, but now in some cases they just need to write a bit less of it :)

--

--