Working with Go's Type-safe Generics
Uncover the nuances of Go's comparable constraint for type-safe generics.
While working on a generic function in Go, I once encountered this error: ‘incomparable types in type set’.
It led me to dig deeper into the comparable
constraint - a seemingly straightforward feature that has profound implications for Go’s generics. This wasn’t my first brush with generics, but it highlighted an important nuance that’s often overlooked. It’s basic, yet surprisingly intuitive and an overlooked facet of Go generics that can save you from unnecessary debugging and potential runtime errors.
With this short post, I’ll walk you through what comparable
is, why it’s useful and how you can leverage it for cleaner, type-safe Go code. If you’re a beginner or even a early mid-level Gopher, this is for you.
Why comparable
?
Go’s type system is famously simple and strict. But when generics entered the scene with Go 1.18, we gained new tools to write reusable, type-safe code. Alongside the introduction of any
(an alias for interface{}
), we got comparable
. So, what’s the deal?
Simply put, comparable
is a constraint that ensures a type supports equality comparisons (==
and !=
).
In Go, only certain types are inherently comparable; these include primitive types like int
, float64
and string
, but exclude types like slices, maps and functions. This is because slices and maps are reference types, meaning their equality cannot be determined by their contents but rather by their memory addresses. Functions, on the other hand, represent pointers to code blocks and are generally incomparable for practical purposes.
By enforcing the comparable
constraint, Go helps ensure that generic functions relying on equality checks don't accidentally allow non-comparable types, preventing hard-to-debug runtime errors. Think of it as a guardrail for writing generic functions or types that rely on comparing values.
Let’s say you want to write a generic function to check if a slice contains a specific value. Without comparable
, you might inadvertently allow types like slices or maps, which are inherently not comparable in Go.
The result? A compiler error when you try to use ==
on those types.
With comparable
, you can enforce at compile time that only valid, comparable types are used. This is incredibly valuable because it eliminates a whole class of runtime errors - like trying to compare slices or maps - before your code even runs. Compile-time checks provide immediate feedback, allowing you to fix issues early and ensuring that your functions behave predictably with the types they’re designed to handle.
In a language like Go that emphasizes simplicity and reliability, this kind of safeguard aligns perfectly with the core philosophy of letting the compiler do the hard work for you.
Here’s an example:
type Array[T comparable] struct {
data []T
}
func (a *Array[T]) Contains(value T) bool {
for _, v := range a.data {
if v == value {
return true
}
}
return false
}
func main() {
arr := Array[int]{data: []int{1, 2, 3, 4, 5}}
fmt.Println(arr.Contains(3)) // Output: true
fmt.Println(arr.Contains(10)) // Output: false
}
Try using a slice or a map as the type parameter and the compiler will immediately stop you in your tracks. This guardrail is what makes comparable
a comparable!
any
vs. interface{}
:
Another player in the Go generics ecosystem is any
, which is simply an alias for interface{}
. Historically, interface{}
has been a cornerstone of Go for representing any type, but its usage often confused newcomers due to its less intuitive name in the context of generics.
The introduction of any
with Go 1.18 aimed to simplify this by providing a more semantic alias, making it immediately clear that the type accepts ‘any’ value. While functionally identical to interface{}
, any
better aligns with the intentions of generics and improves readability, especially in generic function signatures.
Consider this:
func PrintValues[T any](values []T) {
for _, v := range values {
fmt.Println(v)
}
}
Here, using any
makes it clear that the function works with any type, without implying additional constraints.
Compare this to:
func PrintValues[T interface{}](values []T) {
// Same functionality, but less intuitive for generics
}
While interface{}
still works, any
feels more natural in the context of generics. It’s a subtle shift, but one that makes your code more approachable; especially for newcomers.
Real-World Scenarios: When to Use comparable
and any
1. Deduplication with comparable
Here’s a quick example of using comparable
to remove duplicates from a slice:
func RemoveDuplicates[T comparable](input []T) []T {
seen := make(map[T]bool)
result := []T{}
for _, v := range input {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
func main() {
fmt.Println(RemoveDuplicates([]int{1, 2, 2, 3, 4, 4}))
// Output: [1 2 3 4]
fmt.Println(RemoveDuplicates([]string{"go", "go", "lang"}))
// Output: [go lang]
}
2. Flexible Utilities with any
Use any
for functions that don’t rely on constraints. For instance, a simple utility to print all elements in a slice:
func PrintAll[T any](items []T) {
for _, item := range items {
fmt.Println(item)
}
}
func main() {
PrintAll([]int{1, 2, 3})
PrintAll([]string{"hello", "world"})
}
No constraints are needed here and any
signals that the function is open to all types.
Common Pitfalls
1. Trying to Compare Non-Comparable Types
If you’ve ever tried this:
arr := Array[[]int]{data: [][]int{{1, 2}, {3, 4}}}
fmt.Println(arr.Contains([]int{1, 2})) // Compiler error
You’ll get a compilation error because slices are not comparable. A workaround? Wrap your slices in a struct with a custom Equals
method or use a hash function for comparison.
2. Misusing any
Avoid overusing any
when a constraint would make your code safer. For example, consider a function to find the maximum value in a slice:
func FindMax[T any](items []T) T {
max := items[0]
for _, item := range items {
if item > max {
max = item
}
}
return max
}
This code will fail to compile because any
does not imply the ability to compare items using >
. Instead, using T comparable
ensures that the function can safely handle only types that support comparison:
func FindMax[T comparable](items []T) T {
max := items[0]
for _, item := range items {
if item > max {
max = item
}
}
return max
}
By adding the comparable
constraint, you not only fix the compilation issue but also make the function’s intent and requirements clear to anyone using it.
Wrapping Up
Whether you’re searching for a value, deduplicating a slice, or building your own generic utilities, understanding comparable
can save you from unexpected bugs and runtime errors.
So, next time you find yourself scratching your head over an ‘incomparable types’ error, remember: it’s not you - it’s Go, nudging you toward better design (as always :P ). And if you’re still unsure? That’s fine too. Learning the quirks of a language is part of the fun (and frustration) of being a developer.
Thanks for your time! May the code be with you!
My Social Links: LinkedIn | GitHub | 𝕏 (formerly Twitter) | Substack | Dev.to | Reddit
For more content, please consider subscribing to my Substack. Its free!