skip to content

Why does a Go `map[any]bool` compile fine but panic when some keys are inserted?

level: seniorimportance: nice to knowfreq 33%

answer

  1. the static type promised what the value broke
  2. the check moved to run time
  3. the message names the offending type
  4. hashing a slice has no answer
  5. two messages: hashing and comparing

basics

~20 s

The static key type any is comparable, so the map compiles. At run time the map must hash the key's dynamic type, and a slice, map or function value cannot be hashed, so the runtime panics naming that type.

solid answer

~50 s

Comparability is normally a compile-time guarantee, and `any` breaks it. The interface type is statically comparable, so `map[any]bool` type-checks; the actual key type is only known at run time. When the map hashes a key whose dynamic type is a slice, map or function value it cannot proceed, and the runtime panics with a message containing "hash of unhashable type" plus the offending type. The sibling failure is `==` between two interface values whose identical dynamic type is uncomparable, which panics with "comparing uncomparable type". The stack trace names your insert site with `runtime.mapassign` beneath it, which identifies the caller that passed a slice. The fix is to remove the ambiguity, not to recover from it: give the map a concrete key type, canonicalise variable-length input into a string or a fixed-size array, and if the API must accept `any`, reject uncomparable keys at the boundary with `reflect.TypeOf(k).Comparable()`.

code

go · 8 lines
go
var seen = make(map[any]bool)

func mark(k any) { seen[k] = true }

func run() {
	mark("req-1") // fine: dynamic type string
	mark([]string{"req-1"}) // panics: hash of unhashable type []string
}

go deeper

for a junior

Know the headline: slices, maps and function values cannot be map keys, and hiding one inside an interface postpones the error to run time rather than avoiding it.

for a middle

Explain why the compiler accepts an interface key type at all, and what the runtime has to do on every insert and lookup that makes an uncomparable dynamic type impossible to handle.

for a senior

Walk the diagnosis end to end: read the panic message for the offending type, read the trace from your own frame beneath the runtime, identify the caller, and choose between a concrete key type, a canonicalised key and a boundary guard.

for a principal

Frame the root cause as a guarantee traded for convenience: any in a key or parameter type converts a compile error into a production panic. Own the standard that public APIs take concrete key types, and that widening one to any is a review-level decision.

## Why the compiler lets this through Go checks map key types statically: `map[K]V` requires `K` to be comparable. Interface types **are** comparable, so `map[any]bool` — or a key struct with an `any` field — passes that check. What the compiler cannot know is which concrete type will be stored in the interface at run time. Comparability of an interface is a promise about the operator, not about the contents. So the check moves to run time. Every insert and every lookup has to hash the key, and hashing dispatches on the dynamic type. If that dynamic type is one of the three uncomparable kinds — a slice, a map, or a function value — there is no hash function and no equality, and the runtime panics. ## The two messages, and what each one means There are two distinct panic sites, and telling them apart speeds up a postmortem considerably: - **"hash of unhashable type []string"** — the map tried to hash a key. Raised on insert *and* on lookup, from map operations. - **"comparing uncomparable type []string"** — an `==` between two interface values whose identical dynamic type is uncomparable. Raised anywhere you compare two `any` values, including inside a map's collision check. Both name the offending dynamic type, which is the single most useful piece of evidence: it tells you what the caller actually passed. ## Reading the trace The panic's stack trace has the shape: ```text panic: runtime error: hash of unhashable type []string goroutine 42 [running]: runtime.mapassign(...) main.(*cache).mark(...) /src/cache.go:31 main.handle(...) /src/handler.go:88 ``` Read it from the bottom up. The runtime frames tell you *what* failed; your own frames tell you *who* did it. The frame directly above the runtime is the insert or lookup, and the frame above that is usually the real culprit — an API that accepted a slice-shaped value from a caller who thought any key would do. Note also that this panic happens on the goroutine that did the insert; if that goroutine is a request handler with no recovery, the process dies. ## The values that trigger it in practice Rarely does someone deliberately key a map on `[]string`. The usual routes are: - a decoded JSON document handed straight in as a key: objects decode to `map[string]any` and arrays to `[]any`, both uncomparable, - a struct with an `any` field that a caller filled with a slice, - a variadic `...any` audit or dedupe helper whose arguments become keys, - an error value whose concrete type contains a slice field, compared with `==` instead of `errors.Is`. That last one is worth flagging: an error type declared as `type multiErr []error` is uncomparable, so `err == target` panics rather than returning false. ## Fixes, in order of preference 1. **Give the map a concrete key type.** `map[string]bool`, `map[idemKey]bool`, `map[[32]byte]bool` — the compiler is back in charge, and the class of bug disappears. The cost is a canonicalisation step, which must be deterministic: sort what needs sorting and use a separator that cannot occur in the data. 2. **Narrow the public API.** If callers hand you keys, make the parameter a concrete type or a small closed interface. `any` in a signature is what let this reach production. 3. **Guard at the boundary.** When `any` is genuinely required, check before you store: `reflect.TypeOf(k).Comparable()` reports whether the dynamic type supports `==` (guard against a nil `k` first, since `reflect.TypeOf(nil)` returns nil). Return an error instead of panicking. 4. **Test the hostile input.** One unit test that inserts a slice-valued key documents the contract and fails loudly if someone widens the parameter later. ## What does *not* fix it Wrapping the insert in a `recover` hides the defect and leaves the cache in an unknown state. Neither the compiler nor `go vet` can catch it, because the dynamic type is a run-time fact, and the race detector is irrelevant — this is not a concurrency bug. Switching to `reflect.DeepEqual` for comparison avoids the panic but does not give you a hashable key, so it does not help a map at all. ## The postmortem line worth writing The honest root cause is not "someone passed a slice". It is that a type-level guarantee was traded away for API convenience: the moment a key type became `any`, a compile-time error became a production panic. That framing is what stops the same bug appearing next quarter behind a different `any`.

  • One panic says "comparing uncomparable type" and another says "hash of unhashable type". What is the difference?
    Same root cause, two sites. "Hash of unhashable type" comes from a map operation trying to hash a key whose dynamic type is a slice, map or function. "Comparing uncomparable type" comes from `==` between two interface values whose identical dynamic type is uncomparable — which can happen far from any map, for example comparing two error values directly.
  • Does switching the cache to `map[string]bool` remove the risk entirely?
    It removes this panic: a concrete comparable key type puts the rule back under the compiler. What it adds is a canonicalisation step, and that becomes the new correctness risk — the encoding must be deterministic and unambiguous, or two different inputs collide on one key and the cache silently returns the wrong answer. Write it once in a constructor and test it.
  • Would `go vet` or the race detector have caught this before release?
    Neither. The offending type only exists at run time, so no static check sees it, and nothing about it is a data race. The realistic defences are a narrower API signature, a boundary guard using reflection, and a unit test that deliberately passes a slice-valued key.
  • An error type is declared as `type multiErr []error`. What happens when a caller writes `err == target`?
    If both interface values hold `multiErr`, the dynamic types are identical and uncomparable, so the comparison panics instead of returning false. Comparing errors with `==` only works for sentinel values whose concrete type is comparable; `errors.Is` walks the wrap chain and is the safe form for anything else.

saying these in an interview costs you the question

  • Says the compiler should have rejected map[any]bool
  • Claims any value can be a map key at run time
  • Blames the map for not hashing slices by content
  • Wraps the insert in recover and calls it fixed
  • Thinks go vet or the race detector would have caught it