Golang Tutorial: Generics
- 16 minutes read - 3336 wordsOne feature to rule them all.

How often do we encounter significant changes in our preferred programming language? Some languages undergo frequent updates, while others remain traditional and stable. Go falls into the latter category, known for its consistency. “This is not the Go way!” is a phrase that often comes to mind. Most Go releases have focused on refining its existing principles. However, a major shift is on the horizon. The Go team has announced that Generics in Go are becoming a reality, moving beyond mere discussion and into implementation.
Brace yourselves, a revolution is coming.
What are Generics?
Generics allow us to parameterize types when defining interfaces, functions, and structs.
Generics is not a new concept. It has been
used since the first version of Ada, through templates in C++, to its modern implementations in Java and C#. To
illustrate without delving into complex definitions, let’s examine a practical example. Instead of having multiple
Max
or Min
functions like this:
Without Generics
func MaxInt(a, b int) int {
// some code
}
func MaxFloat64(a, b float64) float64 {
// some code
}
func MaxByte(a, b byte) byte {
// some code
}
we can declare now only one method, like this:
With Generics
func Max[T constraints.Ordered](a, b T) T {
// some code
}
Wait, what just happened? Instead of defining a method for each type in Go, we utilized Generics. We used a generic
type, parameter T
, as an argument for the method. With this minor adjustment, we can support all orderable types.
The parameter T
can represent any type that satisfies the Ordered
constraint (we will discuss constraints later).
Initially, we need to specify what kind of type T
is. Next, we determine where we want to use this parameterized
type. In this case, we’ve specified that both input arguments and the output should be of type T
. If we execute
the method by defining T
as an integer, then everything here will be an integer:
Execute Generic Function
func main() {
fmt.Println(Max[int](1, 2))
}
//
// this code behaves exactly like method:
// Max(a, b int) int
And it doesn’t stop there. We can provide as many parameterized types as we need and assign them to different input and output arguments as desired:
Execute some complex Generic Function
func Do[R any, S any, T any](a R, b S) T {
// some code
}
func main() {
fmt.Println(Do[int, uint, float64](1, 2))
}
//
// this code behaves exactly like method:
// Do(a int, b uint) float64
Here we have three parameters: R
, S
, and T
. As we can see from the any
constraint (which behaves like
interface{}
), those types can be, well, anything. So, up to this point, we should have a clear understanding of
what generics are and how we use them in Go. Let’s now focus on more exciting consequences.
Speed, give me what I need
Generics in Go are not the same as reflection.
Before delving into complex examples, it’s essential to check the benchmark scores for generics. Logically, we do not expect performance similar to reflection because if that were the case, we would not need generics at all. Generics are not in any way comparable to reflection and were never intended to be. If anything, generics are an alternative for code generation in some use cases. Our expectation is that code based on generics should have similar benchmark results as code executed in a more traditional way. So, let’s examine a basic case:
A Generic Function for Benchmark
package main
import (
"constraints"
"fmt"
)
type Number interface {
constraints.Integer | constraints.Float
}
func Transform[S Number, T Number](input []S) []T {
output := make([]T, 0, len(input))
for _, v := range input {
output = append(output, T(v))
}
return output
}
func main() {
fmt.Printf("%#v", Transform[int, float64]([]int{1, 2, 3, 6}))
}
//
//
// Out:
// []float64{1, 2, 3, 6}
Here are small methods for transforming one Number
type to another. Number
is our constraint, built on the Integer
and the Float
constraints from the Go standard library (we will cover this topic later). Number
can be any numerical
type in Go, from any derivative of int
to uint
, float
, and so on. The Transform
methods expect a slice with
the first parametrized numerical type S
as the slice’s base and transform it into a slice with the second parametrized
type T
as the slice’s base. In short, if we want to transform a slice of int
s into a slice of float
s, we would
call this method as we do in the main
function. The non-generics alternative for our function would be a method that
expects a slice of int
s and returns a slice of float
s. So, that is what we will test in our benchmark:
Benchmark
func BenchmarkGenerics(b *testing.B) {
for i := 0; i < b.N; i++ {
Transform[int, float64]([]int{1, 2, 3, 6})
}
}
func TransformClassic(input []int) []float64 {
output := make([]float64, 0, len(input))
for _, v := range input {
output = append(output, float64(v))
}
return output
}
func BenchmarkClassic(b *testing.B) {
for i := 0; i < b.N; i++ {
TransformClassic([]int{1, 2, 3, 6})
}
}
//
//
// Out:
// goos: darwin
// goarch: amd64
// pkg: test/generics
// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
//
// first run:
// BenchmarkGenerics
// BenchmarkGenerics-8 38454709 31.80 ns/op
// BenchmarkClassic
// BenchmarkClassic-8 36445143 34.83 ns/op
// PASS
//
// second run:
// BenchmarkGenerics
// BenchmarkGenerics-8 34619782 33.48 ns/op
// BenchmarkClassic
// BenchmarkClassic-8 36784915 31.78 ns/op
// PASS
//
// third run:
// BenchmarkGenerics
// BenchmarkGenerics-8 36157389 33.38 ns/op
// BenchmarkClassic
// BenchmarkClassic-8 37115414 32.30 ns/op
// PASS
No surprises here. The execution time is practically the same for both methods, so using generics does not impact the performance of our application. But are there any repercussions for structs? Let’s try that. Now, we will use structs and attach methods to them. The task will be the same — converting one slice into another:
Another Benchmark
func BenchmarkGenerics(b *testing.B) {
for i := 0; i < b.N; i++ {
Transform[int, float64]([]int{1, 2, 3, 6})
}
}
func TransformClassic(input []int) []float64 {
output := make([]float64, 0, len(input))
for _, v := range input {
output = append(output, float64(v))
}
return output
}
func BenchmarkClassic(b *testing.B) {
for i := 0; i < b.N; i++ {
TransformClassic([]int{1, 2, 3, 6})
}
}
//
//
// Out:
// goos: darwin
// goarch: amd64
// pkg: test/generics
// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
//
// first run:
// BenchmarkGenerics
// BenchmarkGenerics-8 38454709 31.80 ns/op
// BenchmarkClassic
// BenchmarkClassic-8 36445143 34.83 ns/op
// PASS
//
// second run:
// BenchmarkGenerics
// BenchmarkGenerics-8 34619782 33.48 ns/op
// BenchmarkClassic
// BenchmarkClassic-8 36784915 31.78 ns/op
// PASS
//
// third run:
// BenchmarkGenerics
// BenchmarkGenerics-8 36157389 33.38 ns/op
// BenchmarkClassic
// BenchmarkClassic-8 37115414 32.30 ns/op
// PASS
Again, no surprises. Using generics or the classic implementation does not have any impact on the performance of the Go code. Yes, it is true that we did not test too complex cases, but if there were a significant difference, we would have already noticed it. So, we are safe to proceed.
Constraints
If we want to test more complex examples, simply adding any parametrized type and running the application is not enough. If we decide to create a simple example with some variables without any complex calculations, we will not need to add anything special:
A simple Generic Function
func Max[T interface{}](a, b T) (T, T) {
return a, b
}
func main() {
fmt.Println(Max(1, 2))
fmt.Println(Max(3.0, 2.0))
}
//
//
// Out:
// 1 2
// 3 2
If we want to test more complex examples, simply adding any parameterized type and running the application is not
enough. Suppose we decide to create a simple example with some variables without any complex calculations. In that
case, we will not need to add anything special, except that our method Max
does not calculate the maximum value of
its inputs but returns them both. There is nothing strange in the example above.
To achieve this, we use a parameterized type T
, defined as interface{}
. In this example, we should not view
interface{}
as a type but as a constraint. We use constraints to define rules for our parameterized types and
provide the Go compiler with some context on what to expect. To reiterate, we do not use interface{}
here as a
type but as a constraint. We define rules for the parameterized type, and in this case, that type must support
whatever interface{}
does. So, practically, we could also use the any
constraint here. (To be honest, in all the
examples, I have preferred interface{}
instead of any
, to respect the “good old days”.)
During compile-time, the compiler can take a constraint and use it to check if the parameterized type supports
operators and methods that we want to execute in the following code. As the compiler does most of the optimization
at runtime (and therefore, we do not impact runtime, as we could see in the benchmark), it allows only the operators
and functions defined for particular constraints. So, to understand the importance of constraints, let us finish
implementing the Max
method and try to compare the a
and b
variables:
Failed execution of Generic Function
func Max[T any](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(1, 2))
fmt.Println(Max(3.0, 2.0))
}
//
//
// Out:
// ./main.go:6:5: invalid operation: cannot compare a > b (operator > not defined on T)
When we attempt to run the application, we encounter an error — “operator > not defined on T.” Since we defined the
T
type as any
, the final type can be, well, anything. At this point, the compiler does not know how to handle
the >
operator.
To resolve this issue, we must define the parameterized type T
as a constraint that allows such an operator.
Fortunately, thanks to the Go team, we have the Constraints
package, which includes such a constraint. The constraint we want to use is called Ordered
, and after making this adjustment,
our code works perfectly:
Ordered Constraint
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(fmt.Sprintf("%T", Max(1, 2)))
fmt.Println(Max(1, 2))
fmt.Println(fmt.Sprintf("%T", Max(3.0, 2.0)))
fmt.Println(Max(3.0, 2.0))
fmt.Println(fmt.Sprintf("%T", Max[int](1, 2)))
fmt.Println(Max[int](1, 2))
fmt.Println(fmt.Sprintf("%T", Max[int64](1, 2)))
fmt.Println(Max[int64](1, 2))
fmt.Println(fmt.Sprintf("%T", Max[float64](3.0, 2.0)))
fmt.Println(Max[float64](3.0, 2.0))
fmt.Println(fmt.Sprintf("%T", Max[float32](3.0, 2.0)))
fmt.Println(Max[float32](3.0, 2.0))
}
//
//
// Out:
// int --> Max(1, 2)
// 2
// float64 --> Max(3.0, 2.0)
// 3
// int --> Max[int](1, 2)
// 2
// int64 --> Max[int64](1, 2)
// 2
// float64 --> Max[float64](3.0, 2.0)
// 3
// float32 --> Max[float32](3.0, 2.0)
// 3
By using the Ordered
constraint, we achieved the desired result. One interesting aspect of this example is how the
compiler interprets the final type T
, depending on the values we pass to the method. Without specifying the actual
type in square brackets, as shown in the first two cases, the compiler can deduce the type used for the arguments —
in the case of Go, this would be int and float64
.
However, if we intend to use types other than the default ones, such as int64
or float32
, we should explicitly
provide these types in square brackets. This way, we give the compiler precise information about what to expect.
If we wish, we can extend the functionality of the Max
function to support finding the maximum value within an array:
Zero values
func Max[T constraints.Ordered](a []T) (T, error) {
if len(a) == 0 {
return T(0), errors.New("empty array")
}
max := a[0]
for i := 1; i < len(a); i++ {
if a[i] > max {
max = a[i]
}
}
return max, nil
}
func main() {
fmt.Println(Max([]string{}))
fmt.Println(Max([]string{"z", "a", "f"}))
fmt.Println(Max([]int{1, 2, 5, 3}))
fmt.Println(Max([]float32{4.0, 5.0, 2.0}))
fmt.Println(Max([]float32{}))
}
//
//
// Out:
// empty array
// z <nil>
// 5 <nil>
// 5 <nil>
// 0 empty array
In this example, two interesting points emerge:
-
After defining type
T
within square brackets, we can use it in various ways within the function signature, whether as a simple type, a slice type, or even as part of a map. -
To return the zero value of a specific type, we can use
T(0)
. The Go compiler is intelligent enough to convert the zero value into the desired type, such as an empty string in the first case.
We’ve also seen how constraints work when comparing values of a certain type. With the Ordered
constraint, we can use
any operator defined on integers
, floats
, and strings
. However, if we wish to use the ==
operator exclusively,
we can utilize the new reserved word comparable
, a unique constraint that only supports this operator and nothing else:
Comparable Constraint
func Equal[T comparable](a, b T) bool {
return a == b
}
func Dummy[T any](a, b T) (T, T) {
return a, b
}
func main() {
fmt.Println(Equal("a", "b"))
fmt.Println(Equal("a", "a"))
fmt.Println(Equal(1, 2))
fmt.Println(Equal(1, 1))
fmt.Println(Dummy(5, 6))
fmt.Println(Dummy("e", "f"))
}
//
//
// Out:
// false
// true
// false
// true
// 5 6
// e f
In the example above, we can observe the proper usage of the comparable
constraint. It’s worth noting that the
compiler can infer the actual types even without the need to strictly define them within square brackets.
An interesting point to highlight in this example is that we used the same letter, T
, for both parameterized types
in two different methods, Equal
and Dummy
. It’s important to understand that each T
type is defined within the
scope of its respective method (or struct and its methods), and these T
types do not refer to the same type
outside of their respective scopes. This means that you can use the same letter T
in different methods, and the
types will remain independent of each other.
Custom constraints
Creating custom constraints in Go is straightforward. You can define a constraint as any type, but using an interface is often the best choice. Here’s how you can do it:
Custom Constraint
type Greeter interface {
Greet()
}
func Greetings[T Greeter](t T) {
t.Greet()
}
type EnglishGreeter struct{}
func (g EnglishGreeter) Greet() {
fmt.Println("Hello!")
}
type GermanGreeter struct{}
func (g GermanGreeter) Greet() {
fmt.Println("Hallo!")
}
func main() {
Greetings(EnglishGreeter{})
Greetings(GermanGreeter{})
}
//
//
// Out:
// Hello!
// Hallo!
We’ve created an interface called Greeter
to use it as a constraint in the Greetings
method. While we could use
a Greeter
variable directly instead of generics, we’ve used generics here for demonstration purposes.
Type sets
Every type has an associated type set. The type set of an ordinary non-interface type T
consists of just T
itself,
represented as the set {T}
. In the case of an interface type (for this discussion, we are focusing solely on ordinary
interface types without type lists), the type set comprises all types that declare all the methods of that interface.
The definition mentioned above comes from a proposal regarding type sets, and it has already been incorporated into the
Go source code. This significant change has opened up new possibilities for us. Notably, our interface types can now
embed primitive types such as int
, float64
, and byte
, not limited to other interfaces. This enhancement allows
us to define more versatile constraints. Let’s explore the following example:
Custom Comparable Constraint as a Type Set
type Comparable interface {
~int | float64 | rune
}
func Compare[T Comparable](a, b T) bool {
return a == b
}
type customInt int
func main() {
fmt.Println(Compare(1, 2))
fmt.Println(Compare(customInt(1), customInt(1)))
fmt.Println(Compare('a', 'a'))
fmt.Println(Compare(1.0, 2.0))
}
//
//
// Out:
// false
// true
// true
// false
We’ve defined our Comparable
constraint, and it might appear a bit unusual, doesn’t it? The new approach with type
sets in Go now allows us to create an interface that represents a union of types. To specify a union between two types,
we simply include them within the interface and use the |
operator to separate them. In our example, the Comparable
interface constitutes a union of types: rune
, float64
, and indeed, int
. However, int is designated here as an
approximation element.
As demonstrated in the proposal for type sets, the type set of an approximation element T
encompasses not just type
T
itself but also all types whose underlying type is T
. Consequently, by employing the ~int
approximation
element, we can supply variables of our customInt
type to the Compare
method. You’ll notice that we’ve defined
customInt
as a custom type with int
as its underlying type. If we neglect to include the ~
operator, the compiler
will issue an error, preventing the execution of the application. This represents a notable advancement in our
understanding of these concepts.
How far can we go?
We have the freedom to go wherever we please. Seriously, this feature has revolutionized the language. I mean, new code is constantly emerging, and this could have a significant impact on packages that rely on code generation, such as Ent. Starting from the standard library, I can already envision many codes being refactored in future versions to incorporate generics. Generics could even pave the way for the development of an ORM, similar to what we’re accustomed to seeing in Doctrine, for instance.
Let’s take a model from the Gorm package as an example:
Gorm Example
type ProductGorm struct {
gorm.Model
Name string
Price uint
}
type UserGorm struct {
gorm.Model
FirstName string
LastName string
}
Imagine that we want to implement the Repository pattern in Go for both models (ProductGorm
and UserGorm
).
With the current stable version of Go, we can only choose one of the following solutions:
- Write two separate Repository structs.
- Write a code generator that uses a template to create those two Repository structs.
- Decide not to use the Repository pattern.
Now, with generics, the horizon of opportunities has shifted towards a more flexible approach, and we can do something like this:
Gorm and Generics
type Repository[T any] struct {
db *gorm.DB
}
func (r *Repository[T]) Create(t T) error {
return r.db.Create(&t).Error
}
func (r *Repository[T]) Get(id uint) (*T, error) {
var t T
err := r.db.Where("id = ?", id).First(&t).Error
return &t, err
}
So, we have our Repository
struct with a parameterized type T
, which can be anything. It’s worth noting that we
defined T
only in the Repository
type definition, and we simply assigned its associated functions. In this example,
we have only two methods, Create
and Get
, for demonstration purposes. To simplify our demonstration, let’s create
two separate methods for initializing different Repositories:
Declare new Repositories
func NewProductRepository(db *gorm.DB) *Repository[ProductGorm] {
db.AutoMigrate(&ProductGorm{})
return &Repository[ProductGorm]{
db: db,
}
}
func NewUserRepository(db *gorm.DB) *Repository[UserGorm] {
db.AutoMigrate(&UserGorm{})
return &Repository[UserGorm]{
db: db,
}
}
These two methods return instances of Repositories with predefined types, essentially serving as shortcuts. Now, let’s conduct the final test of our small application:
Test new Repositories
func main() {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
productRepo := NewProductRepository(db)
productRepo.Create(ProductGorm{
Name: "product",
Price: 100,
})
fmt.Println(productRepo.Get(1))
userRepo := NewUserRepository(db)
userRepo.Create(UserGorm{
FirstName: "first",
LastName: "last",
})
fmt.Println(userRepo.Get(1))
}
//
//
// Out:
// &{{1 2021-11-23 22:50:14.595342 +0100 +0100 2021-11-23 22:50:14.595342 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}} 100} <nil>
// &{{1 2021-11-23 22:50:44.802705 +0100 +0100 2021-11-23 22:50:44.802705 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}} first last} <nil>
And it works! One implementation for Repository that supports two models, all without the need for reflection or code generation. This is something I never thought I would see in Go.
Conclusion
There’s no doubt that Generics in Go are a monumental change. This change has the potential to significantly alter how Go is used and may lead to numerous refactors within the Go community in the near future. While I’ve been experimenting with generics on a daily basis, exploring their possibilities, I can’t wait to see them in the stable Go version. Viva la Revolution!