skip to content

What makes a Go struct type usable as a map key, and what disqualifies it?

level: middleimportance: should knowfreq 58%

answer

  1. the type is checked, not the value
  2. one bad field poisons the whole struct
  3. arrays are in, slices are out
  4. [16]byte works where []byte cannot
  5. pointer fields key by address, not by content

basics

~20 s

A 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 s

A 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 lines
go
type 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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