Go's comparable Constraint: Why a Struct with a Slice Field Can't Satisfy It
Here's a generic helper that looks entirely reasonable:
func IndexOf[T comparable](s []T, v T) int {
for i, x := range s {
if x == v {
return i
}
}
return -1
}
type Config struct {
Name string
Tags []string
}
func main() {
cfgs := []Config{{Name: "prod", Tags: []string{"eu"}}}
IndexOf(cfgs, cfgs[0])
}
This does not compile. The compiler stops you at the call site with something close to:
./main.go:15:9: Config does not satisfy comparable (struct containing []string cannot be compared)
Nothing about Config looks unusual. It's a plain struct with a string and a slice of strings. But that slice field is enough to disqualify the whole type from ever satisfying comparable, and the reason is more deliberate than it first appears.
What comparable actually checks
comparable isn't a marker interface you opt into by implementing a method. It's a structural property the compiler derives from a type's definition. For a struct, that means recursively walking every field and asking whether each one supports ==. Numeric types, strings, booleans, pointers, channels, interfaces and arrays of comparable element types all pass. Slices, maps and function values don't, because none of them define an == operator at all, only == against nil. A struct is comparable if and only if every field is comparable, full stop. One slice field anywhere in the struct, even nested three levels deep in an embedded type, poisons the whole thing.
This is the same rule that has governed ordinary (non-generic) map keys since long before generics existed. map[Config]int was always going to fail with much the same complaint. Generics just gave the rule a second job: gatekeeping type parameters.
Why slices don't get an == in the first place
This is the bit that's actually interesting, because it's not an oversight, it's a refusal to pick an answer on your behalf. A slice header is three words: a pointer to a backing array, a length, and a capacity. There are at least two defensible meanings for "two slices are equal":
- Reference equality: same backing array, same offset, same length, same capacity. Fast, but says nothing about the contents, and two slices holding identical elements from different allocations would compare unequal, which is almost never what anyone actually wants.
- Value equality: same length and every element equal, pairwise. This is usually what people mean, but it's O(n), and for a language that lets you write
==without thinking about it, silently hiding a linear scan behind a two-character operator is exactly the kind of footgun Go tries to avoid elsewhere.
Rather than bake in one interpretation and surprise half its users, Go just doesn't define == for slices at all (map values get the same treatment, for the same reason). You're allowed to compare a slice to nil, because that's unambiguous, but that's the whole allowance.
Arrays, by contrast, get full value equality, because an array's size is part of its type. [3]int and [3]int are comparable elementwise because the compiler knows at compile time exactly how many comparisons that involves, and the "identity" of an array is its contents, not a pointer to somewhere else. A slice has no such fixed identity, which is really the whole difference: an array is a value, a slice is a view onto one.
The Go 1.20 relaxation doesn't rescue you here
Go 1.20 loosened comparable slightly, and it's worth being precise about what changed, because it's tempting to assume it covers this case and it doesn't. Before 1.20, a type parameter constrained by comparable had to be strictly comparable at compile time. After 1.20, interface types (and type parameters whose constraint includes interfaces) are allowed to satisfy comparable even though their dynamic value might turn out to be incomparable at runtime, in which case the actual == panics instead of failing to compile. That relaxation exists so that things like any can satisfy comparable, deferring the check to when it's actually needed.
None of that applies to Config. The compiler knows, statically, at compile time, without running anything, that Config contains a []string. There's no dynamic type to defer judgement to. The 1.20 change widens the interface/runtime case; it does nothing for a concrete struct with a concrete slice field, which is rejected exactly as eagerly as it always was.
Working around it
The honest fix is to stop asking the compiler to pick an equality semantics it deliberately doesn't have an opinion on, and supply your own. If you actually need element-wise comparison, write it:
func IndexOfFunc[T any](s []T, v T, eq func(a, b T) bool) int {
for i, x := range s {
if eq(x, v) {
return i
}
}
return -1
}
func configsEqual(a, b Config) bool {
if a.Name != b.Name || len(a.Tags) != len(b.Tags) {
return false
}
for i := range a.Tags {
if a.Tags[i] != b.Tags[i] {
return false
}
}
return true
}
For ad hoc cases, reflect.DeepEqual or, since Go 1.21, slices.Equal on the field itself gets you there with less boilerplate:
func configsEqual(a, b Config) bool {
return a.Name == b.Name && slices.Equal(a.Tags, b.Tags)
}
If you actually want Config to work as a map key or a type argument to a comparable-constrained function, the fix is structural, not clever: don't put a slice there. If Tags has a small, fixed maximum size, an array does the job and stays comparable. More often, the right move is to key on something derived from the slice instead, a joined string, a hash, a sorted and stringified form, and keep the slice itself out of the type you're comparing. That derived key is exactly the kind of explicit choice the language wanted you to make instead of quietly comparing pointers or silently looping over elements behind your back.