A Beginner’s Guide to Using Generics in Go

Tai Vong
6 min readAug 18, 2024

The simplicity, effectiveness, and robust type system of Go have made it a popular language. However, it creates many obstacles to implementing something that must be repeated from time to time in order to complete something simple without the ability to reuse. Because of generics, a feature that many developers using many other languages like Java or C++ are using every day, they were absent from it for a very long period of time. Since the release of Go 1.18, Go developers have received support for type parameters, also known as generics. Now the tide has turned.

In this article, we’ll discuss what generics are, their benefits, and how to utilize them in your Go code. Regardless of your level of experience with Go, this will help you understand the fundamentals of generics and how they may improve your projects.

Why will you need Generics?

Imagine you’re building a data structure like a linked list or a binary tree. Prior to Go 1.18, you had to write distinct implementations for each data type you want to store — one for integers, another for strings, and so on. This approach leads to code duplication and increased maintenance overhead. Another way to achieve that is to passing interface{} type from time to time. However, repetitive code or the usage of interface{} types can cause unexpected bugs or harm your program performance.

Generics solve this problem elegantly by allowing you to create a single, type-safe implementation that works with any data type.

There are various advantages can be obtained:

  • Type Safety: Generics guarantee consistency in the type provided to a function or type using the constraint during the compile time, hence decreasing runtime errors.
  • Code Reusability: This prevents code duplication by allowing you to write functions or types once and utilize them with other types.
  • Improved Readability: By eliminating boilerplate code, generic functions and types can improve the readability and maintainability of your software.

Syntax of Generics

Let’s start with a simple example. Suppose you want to write a function that accepts two parameters and returns the greater of those two. You were forced to write unique functions for int, float64, and other types before generics existed. There was no other option.

func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
func MaxFloat64(a, b float64) float64 {
if a > b {
return a
}
return b
}

Now you can create a single function that works with any type by using generics.

func Max[T any](a, b T) T {
if a > b {
return a
}
return b
}

In this example:

  • T is a type parameter. It can be any type.
  • any is a predefined constraint that allows any type. We'll explore constraints in more detail later.

You can then use the Max function with any comparable type:

func main() {
fmt.Println(Max(3, 5)) // Output: 5
fmt.Println(Max(2.5, 7.3)) // Output: 7.3
fmt.Println(Max("apple", "pear")) // Output: pear
}

The Type Constraints

The technique to restrict the kinds that can be used with a generic function or type is to use constraints. The Max function in the preceding example is compatible with any type that allows comparison using the > operator.

You can set your own limitations if you’d like to further limit the types. Let’s make an example constraint that only accepts numeric types:

type Number interface {
int | int32 | int64 | float32 | float64
}

func Add[T Number](a, b T) T {
return a + b
}

Here:

  • The constraint Number annotate the function to only permits int, int32, int64, float32, and float64.
  • Currently, the Add function is limited to numeric types (which supported the + operator). Potential compile-time mistakes are avoided by this constraint, which guarantees that the Add function can only be invoked with numeric types.

Generic Data Structures

Another advantage is that we can use generics to design data structures that work with any type. For example, let’s create a generic stack:

type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
var zero T
return zero
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}

In this example:

  • Stack[T any] defines a generic stack that can hold items of any type.
  • The Push method adds an item to the stack.
  • The Pop method removes and returns the last item from the stack.

You can then create a stack for any type:


func main() {
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
fmt.Println(intStack.Pop()) // Output: 2
stringStack := Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
fmt.Println(stringStack.Pop()) // Output: world
}

Type Inference

As you can see, if we want the Stack to be compatible with a specific type, we should explicitly declare the type along with the usage. Type inference is a mechanism by which the compiler deduces the types of variables or type parameters based on the context in which they are used. When you call a generic function, the compiler can figure out what the type parameter should be based on the arguments you pass to the function. This simplifies the code, making it cleaner and easier to read.

For example, consider a simple generic function:

func Print[T any](value T) {
fmt.Println(value)
}

Here, T is a type parameter that can be any type. When you call Print, Go can infer the type of T based on the argument you pass:

func main() {
Print(42) // T is inferred as int
Print("hello") // T is inferred as string
Print(3.14) // T is inferred as float64
}

In each case, the compiler automatically determines the type of T based on the type of the argument passed to Print.

The ~ Keyword

The ~ keyword is helpful when you want your generic functions or types to work not just with a base type (like int or float64) but also with any user-defined types that have the same underlying type. This is particularly useful when you want to create constraints that are not just limited to a single type but also apply to types that are built on top of that base type.

Let’s start with a basic example. Suppose you want to write a generic function that works with any numeric type. You might define a constraint that allows any type with an underlying type of int:

type Numeric interface {
~int | ~float64
}
func Add[T Numeric](a, b T) T {
return a + b
}

In this example:

  • ~int means any type whose underlying type is int, including int itself and any custom type like MyInt that is defined based on int.
  • ~float64 means any type whose underlying type is float64.

Now, the Add function can be used with int, float64, or any custom type derived from these:

type MyInt int
func main() {
fmt.Println(Add(10, 20)) // T is int
fmt.Println(Add(3.5, 2.5)) // T is float64
fmt.Println(Add(MyInt(10), MyInt(5))) // T is MyInt
}

Without the ~ keyword, the Add function would only work with int and float64, excluding MyInt and other similar types.

Conclusion

In my perspective, Go Generics is truly a game changer. The principle of Go in my belief always is to prefer code generator to meta-programming. The underlying technique behind the Go generics is still code generation because whenever you call the generic function, the compiler will first try to interpolate your argument and compile a new function with appropriate parameters for you. It helps reduce the time spent generating codes for many different types of Go by many times.

Another advantage it brings, in my opinion, is the ability to apply the SOLID principles more easily to your Go code while keeping the DRY principle. Despite the fact that generics are powerful, they should be used judiciously. If a function or type is only ever going to be used with one type, there’s no need to make it generic.

Happy coding!

--

--