Go 1.20 brought a significant evolution to its type system, particularly around the predeclared `comparable` type constraint. For many Go developers, the pre-1.20 behavior of `comparable` within generics was a source of confusion and frustration. While it might seem intuitive that any type supporting comparison operations (like `==`) would satisfy the `comparable` constraint, this wasn’t always the case. Surprisingly, some types that were perfectly “comparable” according to the Go specification failed to satisfy the `comparable` constraint in generic contexts.
Consider a standard map declaration in Go, where `any` (which is a comparable type) is used as a key:
var lookupTable map[any]stringThis works without a hitch. However, when attempting to define a generic map type with `comparable` as a key constraint, the seemingly equivalent usage of `any` would trigger a compile-time error in Go 1.18 and 1.19:
type genericLookupTable[K comparable, V any] map[K]V
var lookupTable genericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)This limitation severely hampered the development of truly generic libraries, such as a `maps.Clone` function that couldn’t handle maps with `any` keys. Go 1.20 changed this, allowing such code to compile successfully. Let’s delve into the intricate language mechanics behind this change.
The Foundations: Go 1.18 Generics and Type Constraints
Go 1.18 marked a major milestone with the introduction of generics, bringing with it the concept of type parameters and type constraints. Just as a function parameter is restricted by its type to a set of values, a type parameter in a generic function or type is restricted by its type constraint to a specific set of types.
This shift also redefined how we perceive interfaces in Go. Traditionally, an interface defined a set of methods. With generics, an interface now explicitly defines a set of types. This new perspective is fully backward compatible; for any method set, we can imagine the infinite set of types that implement those methods. However, the type set view is more powerful, allowing interfaces to explicitly describe type sets. An interface can embed other interfaces, concrete types, unions of types (`A|B`), or all types sharing an underlying type (`~T`). For example:
interface {
~int | ~string
io.Writer
}This constraint describes types whose underlying type is either `int` or `string`, and which also implement `io.Writer`’s `Write` method.
These generalized interfaces are primarily used as type constraints, not as variable types. A common shorthand allows omitting the `interface{}` wrapper for simple type unions:
func min[P ~int64 | ~float64](x, y P) P { … }This `min` function accepts arguments of any `int64` or `float64` underlying type. The operations permitted on values of type parameter `P` within the function body are those supported by *all* types in the constraint’s type set.
Crucially, a type `T` is said to implement an interface `I` if `T` is a member of `I`’s type set. If `T` is itself an interface, its type set must be a subset of `I`’s type set. Before Go 1.20, constraint satisfaction was synonymous with interface implementation.
The Paradox of Comparability: `comparable` vs. Spec-Defined Types
The `==` (and `!=`) operator is unique because it applies to a vast, open-ended set of types, not just predeclared ones. To allow generic code to leverage this, Go 1.18 introduced the predeclared `comparable` interface type. Its purpose was to serve as a constraint for type parameters that needed to support comparison.
However, `comparable` was not initially equivalent to the set of all comparable types as defined by the Go specification. By design, an interface’s type set does not include interfaces themselves. Therefore, `any` (which is `interface{}`), despite supporting `==` for its *values* (though with potential runtime panics), was not included in the `comparable` type set.
Why this distinction? Go differentiates between “comparable” (any type that can be compared, even if it might panic at runtime for certain dynamic values, like an `interface{}` holding a slice) and “strictly comparable” (types that are *guaranteed* never to panic on `==`). `comparable` was designed to represent *strictly comparable* types. This design decision was aimed at providing static type safety, ensuring that `==` operations within generic functions constrained by `comparable` would never panic.
This static safety, while desirable, created a practical impediment. The `any` type, which *can* be used as a map key, couldn’t be used as a key in a generic map constrained by `comparable` because `any` did not *implement* `comparable` (its type set was not a subset of `comparable`’s strictly comparable types). This fundamental disconnect led to a flurry of issues and proposals from the Go community.
Consider the `f` function, which expects a strictly comparable type:
func f[Q comparable]() { … }
func g[P any]() {
_ = f[int] // (1) ok: int implements comparable
_ = f[P] // (2) error: type parameter P does not implement comparable
_ = f[any] // (3) error: any does not implement comparable (Go 1.18, Go.19)
}Here, `int` works because it’s strictly comparable. `P`, a type parameter constrained by `any`, does not implement `comparable` because its type set includes non-comparable types. The concrete type `any` also failed, leading to the “any does not implement comparable” error.
Go 1.20’s Surgical Strike: Differentiating Satisfaction from Implementation
Faced with this dilemma, the Go team made a pragmatic decision in Go 1.20: to separate the concepts of interface implementation and constraint satisfaction. This allowed for a localized exception without fundamentally altering the type set model.
The updated spec for constraint satisfaction now states:
A type T satisfies a constraint C if
T implements C; orC can be written in the form interface{ comparable; E }, where E is a basic interface and T is comparable and implements E.
The second bullet point is the key. It means that if a constraint `C` requires `comparable` (and possibly other methods `E`), it is satisfied by *any type `T` that supports `==`* (and also implements `E`), even if `T` isn’t strictly comparable and therefore doesn’t *implement* `comparable` in the traditional sense.
This exception has a direct impact on our previous example:
func f[Q comparable]() { … }
func g[P any]() {
_ = f[int] // (1) ok: int satisfies comparable
_ = f[P] // (2) error: type parameter P does not satisfy comparable
_ = f[any] // (3) ok: satisfies comparable (Go 1.20)
}In Go 1.20, `any` now *satisfies* `comparable` (case 3). This is because `any` values can be compared with `==` (even with the runtime panic potential), and the constraint `comparable` fits the exception rule (where `E` is an empty interface). This change fixed the core issue, allowing `any` to be used in generic contexts like `genericLookupTable[any, string]`.
Interestingly, `P` (a type parameter constrained by `any`) *still does not* satisfy `comparable` (case 2). This is critical. The `==` operation is *not* guaranteed for all types within `P`’s type set. Therefore, the exception doesn’t apply to type parameters themselves. This subtle distinction maintains the enforcement of strict comparability for type parameters in most scenarios, providing a nuanced balance between flexibility and safety.
Navigating the New Landscape: Static Safety and Workarounds
Introducing an exception to a carefully constructed type system, even a localized one, comes with consequences. The primary drawback is a slight reduction in static type safety for generic functions using `comparable` when `any` is involved. In Go 1.20, while `var lookupTable genericLookupTable[any, string]` compiles, it can still lead to a runtime panic if a non-comparable key (like a slice) is inserted, mirroring the behavior of the built-in `map[any]string`.
This means generic functions that rely on `comparable` might now encounter runtime panics for `==` operations, even if the declaration suggests strict comparability. The compiler can no longer guarantee complete static safety in these specific edge cases. We’ve traded some compile-time checks for increased expressiveness and flexibility, allowing Go to handle situations that were previously compile-time errors.
For scenarios where strict comparability *must* be enforced at compile time, Go provides a clever workaround leveraging the rule that type parameters *do not* benefit from the `comparable` satisfaction exception. This allows us to create a compile-time assertion for strict comparability for a given type `T`:
func _[P T]() {
_ = f[P] // `f` is our helper function: func f[Q comparable]() {}
}In this construct, the blank identifier `_` prevents unused variable errors. The crucial part is `f[P]`. If `T` is not strictly comparable (e.g., an interface that can hold non-comparable types), then `P` (a type parameter constrained by `T`) will not satisfy `comparable`, leading to a compile-time error for `f[P]`. This provides a mechanism to regain static strict comparability checks where needed.

Photo by La-Rel Easter on Unsplash
The journey of Go’s `comparable` type constraint from its initial implementation in Go 1.18 to its refined behavior in Go 1.20 exemplifies the iterative nature of language design. It highlights the delicate balance between theoretical purity (consistent type set model) and practical utility (enabling widely expected generic patterns). The solution, an exception to a fundamental rule, was carefully considered to address a significant pain point without broadly undermining the language’s core principles.
As developers, understanding these nuanced changes is crucial for writing robust and efficient generic Go code. It prompts us to consider: how often do such “pragmatic exceptions” truly enhance a language’s usability, versus adding layers of complexity that challenge its elegant foundations?




