What makes a Go struct type usable as a map key, and what disqualifies it?
answer
- the type is checked, not the value
- one bad field poisons the whole struct
- arrays are in, slices are out
- [16]byte works where []byte cannot
- pointer fields key by address, not by content
basics
~20 sA struct can key a Go map only if every field type is comparable, applied recursively. One slice, map or function field disqualifies it at compile time; replace that field with an array, a string or another canonicalised value.
solid answer
~50 sA map key type must be comparable, and a struct type is comparable exactly when every field type is — checked recursively through nested structs and arrays. So a dedupe or idempotency cache keyed by `struct{ Tenant, Op string; Request [16]byte }` works: strings and arrays of comparable elements are comparable, and equality is field-by-field and structural, so a key rebuilt from the same request finds the same entry. Change `Request` to `[]byte` and the type stops compiling as a key at all — the compiler reports an invalid map key type, regardless of what the slice holds. Pointer fields are comparable but compare by *address*, which usually makes them the wrong thing in a cache key. When a key needs variable-length data, canonicalise it: sort and join into a string, or hash it into a fixed-size array.
code
go · 9 linestype idemKey struct {
Tenant string
Op string
Request [16]byte // comparable; a []byte field would not compile
}
var applied = make(map[idemKey]int64) // key -> unix time applied
func markApplied(k idemKey, at int64) { applied[k] = at }go deeper
Remember the headline: a map key must be comparable, and a struct is comparable only when all of its fields are. Know that a slice, map or function field rules the struct out entirely.
Be ready to walk the recursive check through nested structs and arrays, and to explain why [16]byte is a legal field in a key while []byte is not, including what error the compiler reports and where.
Show judgment about which fields belong in a key: pointer fields keying by address, time.Time carrying a monotonic reading and a location pointer, and any fields deferring the check to run time. Insist on one constructor so keys are built identically everywhere.
Own the convention for types other teams will key on: keep key structs small, comparable and stable, keep variable-length data out of them, and treat adding a slice or interface field to a shared value type as a breaking change.
## The rule, stated once `map[K]V` requires `K` to be **comparable**, because the map has to hash a key and then test candidate keys with `==`. For a struct type, comparability is defined recursively: a struct is comparable **if and only if every field type is comparable**. Nothing about the runtime values enters into it — the compiler settles it when it type-checks the map. That gives a simple mental checklist for a struct you intend to use as a key. Every field must be one of: - a boolean, numeric or string type, - a pointer or channel type (compared by identity), - an interface type (comparable statically — with a run-time caveat below), - an array whose element type is comparable, - another struct type that passes the same checklist. And no field may be a **slice**, a **map**, or a **function value**. One of those anywhere in the tree poisons the whole struct. ## Why struct keys are worth reaching for A typical use is an in-memory idempotency or dedupe cache: you want "have I already applied this operation for this tenant?" and the natural key is a small tuple. Go lets you write that tuple as a struct instead of jamming the parts into a delimited string: ```go type idemKey struct { Tenant string Op string Request [16]byte } var applied = make(map[idemKey]int64) // key -> unix time it was applied ``` Equality here is **structural**: two `idemKey` values built independently, in different goroutines, from different requests, are equal whenever all three fields are equal. That is exactly what a dedupe cache wants, and it is safer than string concatenation because there is no separator to escape and no chance of `"a|bc"` colliding with `"ab|c"`. The array field is the part people get wrong. `[16]byte` is comparable and copies by value, so a request id, a UUID or a truncated hash slots in perfectly. `[]byte` is a slice and is not comparable, so it cannot appear in a key type at all — the fix is `[16]byte(sum)` or a hex string, not a nil check. ## What breaks, and what the compiler says ```go type badKey struct { Tenant string Tags []string // slice field: badKey is not comparable } var _ map[badKey]int // compile error: invalid map key type badKey ``` Notice where the error appears: at the map type, not at the insert. There is no way to "only use safe values" — comparability is not a property of the values. ## Fields that compile but surprise you Two field kinds are legal in a key and still frequently wrong: **Pointer fields.** `*Customer` is comparable, but it compares the *address*. Two lookups that build otherwise identical keys around two different `*Customer` values pointing at equal customers will miss each other. If you want value semantics, put the customer's id in the key, not the pointer. **`time.Time` fields.** `time.Time` is a comparable struct, so it compiles. But `==` on it also compares the monotonic clock reading and the `*Location` pointer, so two `Time` values naming the same instant can be unequal. The documented remedies are to strip the monotonic reading with `t.Round(0)`, normalise the location with `t.UTC()`, or — best for a key — store an integer such as `t.UnixNano()`. **Interface fields.** An `any` field keeps the struct statically comparable, but pushes the check to run time: if that field ever holds a slice, map or function value, inserting the key panics. A struct meant to be a key should avoid `any` fields unless the API can guarantee what goes in. ## Unexported fields count Comparison includes fields you cannot see. A struct imported from another package compares on all of its fields, exported or not, so a library that adds a hidden cache field to a value type can silently change what your map treats as "the same key". This is one reason library authors document whether a type is safe to compare. ## When the key really is variable-length Canonicalise, and be explicit about the canonical form: - sort a tag list and join it with a separator that cannot occur in a tag, - or hash the canonical bytes into `[32]byte` and key on that, - or store a small fixed-arity struct and put the variable part in the *value*, not the key. Whichever you choose, write it once in a constructor function so every call site produces byte-identical keys; a dedupe cache whose keys are built two different ways is a dedupe cache that silently misses.
- Your dedupe key needs a variable-length list of tags. How do you keep it usable as a map key?Canonicalise the list into something comparable: sort it and join with a separator that cannot appear in a tag, or hash the canonical bytes into a `[32]byte` field. Build the key in one constructor function so every call site produces identical keys. A `[]string` field can never work — the type is rejected, not the value.
- Is a struct with a `time.Time` field a safe map key?It compiles, because `time.Time` is a comparable struct, but `==` also compares the monotonic clock reading and the `*Location` pointer, so two Times naming the same instant can be unequal. Strip the monotonic reading with `t.Round(0)`, normalise with `t.UTC()`, or store `t.UnixNano()` as an integer field instead.
- Do unexported fields of a struct participate in map-key equality?Yes. Comparison is field by field over every field, exported or not, so a struct from another package is keyed on state you cannot see. That is why library types document whether they are safe to compare, and why a hidden field added in a later version can change what your map considers the same key.
saying these in an interview costs you the question
- Says any struct can key a map as long as no field is nil
- Thinks a []byte field is fine because the map hashes bytes
- Believes struct keys are matched by pointer identity
- Assumes a *T field compares the pointed-to value
- Says the compiler only complains when a key is inserted