Go Generics: Applying the Draft Design to a Real-World Use Case

Use case Golang SecretHub

Last Tuesday, the Go team published an updated draft design for Generics in Golang, and they are providing an experimentation tool, which lets you play around with generics. This makes it easier to see what your code would look like when using generics.

We’re super excited about this and can’t wait to try it out! We’ll be using our use case to show what impact generics could have on production code. We’re currently using empty interfaces to extract shared code, and with the introduction of generics, code would be simpler and statically typed.

Our Use Case: Pagination

Just like any API, our API needs pagination too. Whether we’re fetching secrets, audit events, or directories, the pagination works in the exact same way for every resource.

Knowing that these resources work the same way, we don’t want to lose time coding this for every single resource separately. On top of that, we have no interest in maintaining WET code.

Current Approach: Empty Interfaces

Our current approach kind of solves this problem: shared behavior is represented in shared code. And here is what it looks like:

  • Our shared code accepts interface{} parameters so that arguments of any type can be passed.
  • Callers of our shared code convert typed slices to []interface{} so they can be passed.
  • We wrap our shared code every time it’s exposed to our users, so that we can add types.

That sounds great in theory, but what does it look like in code? To follow along, we’ve created a simplified version of our code on the go playground.

Shared Code Accepts Empty Interfaces

Note that our shared code accepts and returns empty interfaces:

type Paginator interface {
	Next() ([]interface{}, error)
}
func (it *Iterator) Next() (interface{}, error)

Callers Convert Arguments

Our shared code accepts a slice of empty interfaces: []interface{}. Our typed slice, []Event, doesn’t satisfy this, so the caller of the shared code has to convert the typed slices into slices of empty interfaces:

func (pag *EventPaginator) Next() ([]interface{}, error) {
	events := make([]Event, 50)
	// logic for fetching the events omitted

	res := make([]interface{}, len(events))
	for i := 0; i < len(events); i++ {
		res[i] = events[i]
	}
	return res, nil
}

Shared Code is Wrapped to Add Typing

It’s bad enough that we lose static typing in our internal code, and we certainly don’t want to bother our users with this. So whenever we’re returning an iterator to our users, we wrap it in a typed version first:

type EventIterator struct {
	iterator Iterator
}

func (iter *EventIterator) Next() (Event, error) {
	event, err := iter.iterator.Next()
	if err != nil {
		return Event{}, err
	}
	return event.(Event), nil
}

Why This Is a “Not So Great” Solution

Using empty interfaces, we lose static typing. This means that if we were to make a mistake and mix paginators and iterators working on different types, our compiler can’t help us anymore.

computer says no meme
The compiler helps us catch mistakes early.

What’s more, we’re writing a lot of boilerplate code to convert back and forth between typed resources and empty interfaces. Both the conversion in the callers and the wrappers don’t contain any real logic.

And it adds up: for an API with 20 resources, that means having 360 extra lines of code for conversion and 20 extra types for the wrappers.

Using Generics

Now, let’s see how generics can improve this. You can check out the full code example here.

Shared Code uses Generic Types

Where our shared code used to accept and return empty interfaces, we can now use a generic type:

type Paginator(type T) interface {
	Next() ([]T, error)
}
func (iter *Iterator(T)) Next() (T, error)

Welcome back, static typing! 🎉

Callers Use Concrete Types

Using a generic type in the Paginator interface enables us to use a concrete type in its implementation:

func (pag *EventPaginator) Next() ([]Event, error)

Bye bye conversion!

No Wrapping Required

Where we used to return the specific types that wrapped our Iterator implementation, for example, EventIterator, we now use Iterator(Event).

Instead of converting in a wrapper class, we now create an instance of the generic code that operates on the concrete type. This provides the type safety without writing and maintaining extra code!

Thoughts

Using generics as proposed in the draft, we were able to remove a lot of boilerplate conversion code between typed and untyped resources. Furthermore, we were able to statically type our iterator again, which means bugs from mixing types will be caught at compile time.

Overall, we’re really happy with what generics have to offer, and we’re looking forward to seeing the proposal proceed further. 🎉

In the meantime, we’d love to hear what you think! Let us know on Twitter or Discord.