Articles / Go

  • go
  • generics
  • comparable
  • slices
  • type-constraints
  • maps

Why Structs with Slices Can't Satisfy comparable in Go

Here's a generic helper that looks entirely reasonable, and doesn't compile:

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])
}

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 one slice field disqualifies the whole type from ever satisfying comparable, and the reason is more deliberate than it first appears.

comparable is derived from every field, recursively

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. 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 poisons the whole thing, even nested three levels deep in an embedded type.

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.

Slices have no == because Go refuses to pick a meaning

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 get equality because their size is in the type

Arrays, by contrast, get full value equality. [3]int and [3]int are comparable elementwise because the compiler knows at compile time exactly how many comparisons that involves. The "identity" of an array is its contents, not a pointer to somewhere else.

A slice has no such fixed identity, and that's 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. It's tempting to assume it covers this case, and it doesn't, so it's worth being precise about what changed.

  • 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) may satisfy comparable even though their dynamic value might turn out to be incomparable at runtime. In that 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, 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.

Supply your own equality instead

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)
}

Or keep the slice out of the type you compare

If you 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, and keep the slice itself out of the type you're comparing:

  • a joined string
  • a hash
  • a sorted and stringified form

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.