Advanced Generics in Go

go dev.to

Go generics were released in Go 1.18. Although they can be very useful, they are not very welcomed inside the Go community.

If you have used generics, you know that they can be very useful for certain occasions, but they also feel quite limited (especially if you compare them with other programming languages).

For example, you can use generics in Go to convert something like this:

// SumInts adds together the values of m.
func SumInts(m map[string]int64) int64 {
    var s int64
    for _, v := range m {
        s += v
    }
    return s
}

// SumFloats adds together the values of m.
func SumFloats(m map[string]float64) float64 {
    var s float64
    for _, v := range m {
        s += v
    }
    return s
}
Enter fullscreen mode Exit fullscreen mode

to a unique function:

// SumIntsOrFloats sums the values of map m. It supports both int64 and float64
// as types for map values.
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
    var s V
    for _, v := range m {
        s += v
    }
    return s
}
Enter fullscreen mode Exit fullscreen mode

And then with that one function can be used with multiple inputs:

SumIntsOrFloats[string, int64](ints)
SumIntsOrFloats[string, float64](floats)
Enter fullscreen mode Exit fullscreen mode

Example extracted from Tutorial: Getting started with generics.

But, do you also know other features about generics in Go that are not that common?

Some Advance Features

Argument Type Inference

This is a feature that can be very useful to make your functions easier to use, Go can only use Argument Type Inference if the type parameter is used in the input arguments of the function.

The compiler automatically deduces the type arguments from the functions' input parameters, allowing you to omit explicit type instantiation.

If a type parameter is only used in the return values, Go has no input data to look at, and inference will fail.

More info can be found in Type Inference.

To explain it better, let's look at the following example:

func ParseJSON[T any](jsonStr string) (T, error) {
    var result T
    err := json.Unmarshal([]byte(jsonStr), &result)
    return result, err
}
Enter fullscreen mode Exit fullscreen mode

In this function, Go can't figure out what is the return type (T is not part of the input parameters), so then it has to be specified in the caller function:

user, err := ParseJSON[User](jsonData)
Enter fullscreen mode Exit fullscreen mode

However, if we change the signature of the function, to be something like:

func ParseJSON[T any](jsonStr string, result T) error {
    return json.Unmarshal([]byte(jsonStr), &result)
}
Enter fullscreen mode Exit fullscreen mode

Then we can call the function without specifying the generic type:

var user User
err := ParseJSON(jsonData, &user)
Enter fullscreen mode Exit fullscreen mode

Playground.

Constraint Type Inference

Constraint type inference is another feature of the Go generics in which the compiler can deduce type arguments from type parameter constraints. We need a minimum of two type parameters in our function signature, and then one type parameter has a constraint defined in terms of another type parameter.

I don't have the feeling I can explain it better than the Go developers explained in Constraint Type Inference, so I recommend you to take a look at it and look at the example.

Going Further

By making use of all the features that the Go generic provides, we can create very powerful and constrained signatures in our APIs.

Requiring a Pointer at Compile Time

We can use Constraint Type Inference to force our function to be used with a pointer (or a value), by doing the following:

  • Define a type, e.g. T any.
  • Define another type as a pointer of the previous type, e.g. PT *T.
  • Define an input parameter of the pointer type, e.g. pt PT.

Then, the compiler can force (at compilation time) that what we are passing a pointer as a parameter in our function.

Let's see it with an example:

// We define a type T.
// We define another type that depends on the previous type `PT *T`.
func ParseJSON[T any, PT *T](jsonStr string, result PT) error {
    return json.Unmarshal([]byte(jsonStr), result)
}
Enter fullscreen mode Exit fullscreen mode

Then we get a compilation error if we call this function like:

var user User
err := ParseJSON[User, *User](jsonData, user) // compilation error, since it's expecting a pointer.
Enter fullscreen mode Exit fullscreen mode

But not if we call it like this:

var user User
err := ParseJSON[User, *User](jsonData, &user)
Enter fullscreen mode Exit fullscreen mode

I must confess that it was mind-blowing 🤯 when I discover this.

But... we can take it one step forward, because the input parameter is the type parameter, we can make use of Argument Type Inference to make it easier to use, and we can move from:

err := ParseJSON[User, *User](jsonData, &user)
Enter fullscreen mode Exit fullscreen mode

to

err := ParseJSON(jsonData, &user) // type is inferred from user
Enter fullscreen mode Exit fullscreen mode

Playground.

Interfaces as Type Constraints, Even Unexported Ones

A typical use of an interface as a type constraint looks like this:

func Stringify[K cmp.Ordered, V fmt.Stringer](m map[K]V) map[K]string {
    result := make(map[K]string, len(m))
    for k, v := range m {
        result[k] = v.String()
    }
    return result
}
Enter fullscreen mode Exit fullscreen mode

Now, imagine that we create our own interface, like:

type Loggable interface {
  Log()
}
Enter fullscreen mode Exit fullscreen mode

And we use it in our functions like:

func LogIt[L Loggable](l L) {
    ...
}

...
// another package

mypackage.LogIt(l)
Enter fullscreen mode Exit fullscreen mode

That works as expected, can you guess what happens if we make the Loggable interface non-exported?

type loggable interface {
  Log()
}

func LogIt[L loggable](l L) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Do you think we get a compilation error if we call it like?:

mypackage.LogIt(l)
Enter fullscreen mode Exit fullscreen mode

Surprisingly, we don't. Go's type inference checks whether the concrete type satisfies the interface structurally. The interface does not need to be exported for the compiler to verify this.

A Real Use Case

I used all of these techniques together in my library logevent. The library helps you attach a structured event to a request context, update it during the request's lifetime, and log it at the end.

Then, some of the requirements I wanted my function's signature to have, were

  • You pass as a parameter the struct that you are going to update during the request, but it needs to be a value (not a pointer), since that is going to be copied and added to the context.Context on every request.
  • The struct needs to implement an interface with the method Log that can receive any parameter, since there is not a unique way to define the log interface (slog, logrus, etc., they have different signatures for their log methods).

At the end I ended up with this:

func AddLogEventMiddleware[L, T any, PT internal.PtrLogEvent[L, T]](
    t T,
    logger L,
) func(http.Handler) http.Handler
Enter fullscreen mode Exit fullscreen mode

A function with three type parameters.

Where:

package logevent

type LogEvent[L any] interface {
    Log(ctx context.Context, li L)
}

package internal

type PtrLogEvent[L any, T any] interface {
    *T
    logevent.LogEvent[L]
}
Enter fullscreen mode Exit fullscreen mode

Let's explain both types separately.

LogEvent[L any]

LogEvent is an interface that has one method, Log. Which contains two parameters, context.Context, and a second parameter that can be anything, L type (since every log implementation has their own logging method signatures).

PtrLogEvent[L any, T any]

This is the tricky type. I defined that PtrLogEvent needs to represent a pointer to the type T. That makes forces that the parameter t of AddLogEventMiddleware can only be a value:

func AddLogEventMiddleware[L, T any, PT internal.PtrLogEvent[L, T]](
    t T, // by defining PtrLogEvent as *T, this means that here it's expecting a value, not a pointer.
    logger L,
) func(http.Handler) http.Handler
Enter fullscreen mode Exit fullscreen mode

PtrLogEvent also implements logevent.LogEvent, meaning that the type *T needs to implement logevent.LogEvent.

So then this type constraint that the value that I pass to my function needs to be a value, and implement LogEvent interface.


Thanks to argument type inference and constraint type inference, the caller does not need to spell out any type parameters:

logeventmiddleware.AddLogEventMiddleware(myLogEvent{}, slog.Default())
Enter fullscreen mode Exit fullscreen mode

Everything is inferred from the arguments.


I hope this tour of Go's more advanced generics features gave you a few new ideas. Used carefully, they can help you build APIs that are both flexible and hard to misuse.

If you enjoyed this, check out the logevent repository for the full code.

Source: dev.to

arrow_back Back to Tutorials