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
}
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
}
And then with that one function can be used with multiple inputs:
SumIntsOrFloats[string, int64](ints)
SumIntsOrFloats[string, float64](floats)
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
}
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)
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)
}
Then we can call the function without specifying the generic type:
var user User
err := ParseJSON(jsonData, &user)
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)
}
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.
But not if we call it like this:
var user User
err := ParseJSON[User, *User](jsonData, &user)
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)
to
err := ParseJSON(jsonData, &user) // type is inferred from user
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
}
Now, imagine that we create our own interface, like:
type Loggable interface {
Log()
}
And we use it in our functions like:
func LogIt[L Loggable](l L) {
...
}
...
// another package
mypackage.LogIt(l)
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) {
...
}
Do you think we get a compilation error if we call it like?:
mypackage.LogIt(l)
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.Contexton every request. - The struct needs to implement an interface with the method
Logthat 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
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]
}
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
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())
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.