Adapting
Go packages to
use generics

Mar 21, 2022

How do you change a package that uses strings to generic types? Like many I have packages that work with a single type — but solve a general problem. In this article we look at how convert these to use generics.

Introducing the protagonist

The package has a single public type, a constructor like function, and a few methods.

type Unique
func New() *Unique
func (s *Unique) Distinct() []string
func (s *Unique) Set(key, value string)
func (s *Unique) Clear(key string)

The objective is to change the key parameter to a generic type such that it can be an int, uint, float64, string, and so on.

Prior to generics, it was required to introduce a compromise—in terms of type-safety with the empty any type, as a performance and memory penalty by extra layers of indirection. Or as code duplication by copy-paste similar implementations.

Before we continue, take a closer look at the current package.

Use New to instantiate local state and Set to create references from keys to values.

names := unique.New()
names.Set("key-1", "Francis")
names.Set("key-2", "Frida")
names.Set("key-3", "Frida")

Call Distinct() to retrieve a slice of unique values.

values := names.Distinct()
fmt.Printf("%q\n", values)
// Output: ["Francis" "Frida"]

And Clear to value reference for a given key.

names.Clear("key-1")

The goal is to make Set and Clear accept any comparable key parameter type— and in the next section, we look at how to achieve that.

Adapting generics

How can make methods accept generic type parameters?

There is a new builtin comparable type. It's an interface implemented by all comparable types. A comparable type supports the operations == and !=. In Go comparable types are booleans, numbers, strings, pointers, channels, arrays of comparable types, and structs whose fields are all comparable types.

To change Clear and Set's signature so that the key parameter accepts types that implement comparable — we can try this:

func (s *Unique) Clear(key comparable)
func (s *Unique) Set(key comparable, value string)

Yet that doesn't work.

The comparable interface may only be used as a type parameter constraint, not as the type of a variable.

What are type parameters and parameter constraints?

In Go 1.18 the syntax for a function and type declaration accept type parameters. A function or type may include a list of type parameters. The list of type parameters looks like function parameters except that they are enclosed in square brackets. Type parameters limit the set of types you can instantiate a function or type with.

With that in mind, we add the comparable interface type as a type parameter K for Clear and Set

func (s *Unique) Set[K comparable](key K, value string)
func (s *Unique) Clear[K comparable](key K)

The compiler still complains!

type parameters are not supported on methods

The spec says they are supported on functions and types. Clear and Set are methods. The type parameter and parameter constraint must be on the type the methods are bound to.

type Unique[K comparable] struct {
    ...
}

The methods can then reference K and use it as a generic type for the key parameter.

func (s *Unique[K]) Set(key K, value string)
func (s *Unique[K]) Clear(key K)

Notice how Unique's definition changed as well. Finally, the parameterized type can be instantiated with a comparable type—like an int in the example below

u := &unique.Unique[int]{}

When u is used as a receiver for Set and Clear they expect that the key argument is an int. Success! The package is changed and the key parameter can be any type that satisfies the comparable interface.

There is one more problem. What about the New function?

Solving the constructor problem

A common idiom is to have a constructor function that returns a pointer to an instance of a type, with required properties instantiated. It is especially useful with maps and other types that are allocated on the heap.

func New() *Unique

How to instantiate New with type arguments so it

  1. Returns a parameterized pointer to Unique
  2. Allocates internal structures with the type parameter

Define New so that it requires a type parameter P that implements the comparable interface. Then pass P as a type argument to instantiate Unique and internal structures, such as a map of keys. Finally, change the return type to be a pointer to a parameterized instance (*Unique[P]).

This is easier to understand with code.

func New[P comparable]() *Unique[P] {
    return &Unique[P]{
        ids:      make(map[P]uint),

        ...
    }
}

At last, the constructor returns an instance of the generic type.

In a nutshell

The package is changed so that Set and Clear accept parameters that satisfy the builtin comparable interface, the constructor is updated to return a parameterized instance of the type.

Usage with an int as key.

names := unique.New[int]()
names.Set(1, "Frida")

Or a string.

names := unique.New[string]()
names.Set("key-1", "Frida")