Generics facilitators in Go

Go 1.18 is going to be released with generics support. Adding generics to Go was a multi-year effort and was a difficult one. Go type system is not a traditional type system and it was not possible just to bring an existing generics implementation from other language and be done. The current proposal was accepted after years of user research, experiments and discussions. The proposal got iterated a few times during the implementation phase. I found the final result delightful.

The proposal notes several limitations of the current implementation. When I was first reviewing the proposal a year ago, one limitation stood out the most: no parameterized methods. As someone who has been maintaining various database clients for Go, the limitation initially sounded as a major compromise. I spent a week redesigning our clients based on the new proposal but felt unsatisfied. If you are coming from another language to Go, you might be familiar with the following API pattern:

// NOTE: NOT POSSIBLE TO COMPILE THIS CODE AT THE MOMENT.

db, err := database.Connect("....")
if err != nil {
    log.Fatal(err)
}

all, err := db.All[Person](ctx) // Reads all person entities
if err != nil {
    log.Fatal(err)
}

Once you open a connection, you can share among entity types by writing generic methods that have access to the connection. We often see this pattern implemented as generic methods, and not being able to compile the snippet above felt unideal to achieve a similar result. Being able to write generic method bodies in cases like above are useful for framework developers to handle boilerplate and common tasks in the framework while keeping the implementation generic from entity types.

Facilitators

If you are looking into the Go generics for the first time, it may not be immediately clear at first that the language allows to have parameterized receivers. Parameterized receivers are a useful tool and helped me develop a common pattern, facilitators, to overcome the shortcomings of having no parameterized methods. Facilitators are simply a new type that has access to the type you wished you had generic methods on. For example, if you are an ORM framework designer and want to provide several methods of querying a table, you introduce an intermediate type, Querier, and do the wiring to Client via NewQuerier. Then, Querier allows you to write generic querying functions against types provided by your users. I found it useful to keep the facilitators in the same package to have full access to unexported fields but it’s not necessarily.

package database

type Client struct{ ... }

type Querier[T any] struct {
	client *Client
}

func NewQuerier[T any](c *Client) *Querier[T] {
	return &Querier[T]{
		client: c,
	}
}

func (q *Querier[T]) All(ctx context.Context) ([]T, error) {
	// implementation
}

func (q *Querier[T]) Filter(ctx context.Context, filter ...Filter) ([]T, error) {
	// implementation
}

Later, your users can crete a new querier of any entity type and use the existing client connection to query the database:

var client *database.Client // initiate

querier := database.NewQuerier[Person](client)
all, err := querier.All(ctx)
if err != nil {
    log.Fatal(err)
}

for _, person := range all {
    log.Println(person)
}

Facilitators make the limitation of having no generic methods disappear and they add only a tiny bit of verbosity.There is nothing novel about this pattern but it needs a name for those who don’t know how to use parameterized receivers to achieve the same.

A playground is available in case you want to try it out.