Why should a context.WithValue key be an unexported custom type rather than a plain string?
answer
- one flat namespace, every package
- a plain string is not yours alone
- equality compares the dynamic type too
- no other package can name your type
- unexported key type plus exported helper funcs
basics
~20 sContext values share one namespace across every package in a program. A key of an unexported type cannot be constructed or matched by any other package, so two libraries that both use the string "userID" cannot overwrite each other's value.
solid answer
~40 s`context.WithValue` stores one key and one value per node, and `Value` matches keys with `==` on the interface, which compares the dynamic type as well as the data. A plain `"userID"` string therefore equals every other package's `"userID"`, so an unrelated library can read your value or shadow it, with no compiler error and no runtime warning. The fix is a key type nobody else can name: `type traceIDKey struct{}`, or `type ctxKey int` with unexported constants, declared unexported in your package. Because callers cannot construct the key, you also export a small pair of helpers — `WithTraceID(ctx, id)` and `TraceID(ctx) (string, bool)` — which is the real benefit: the value's type and its missing-value behaviour become part of a checked API instead of an assertion repeated at every call site.
code
go · 11 lines// Unexported: no other package can construct this key, so it cannot collide.
type traceIDKey struct{}
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey{}, id)
}
func TraceID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceIDKey{}).(string)
return id, ok
}go deeper
Be ready to write the three lines from memory: an unexported key type, a With helper, and a getter that uses a comma-ok assertion. Say plainly that a string key can be produced by any other package and therefore collide.
Explain the mechanism, not just the rule: keys are compared as interface values, so dynamic type participates in equality, and WithValue panics on a nil or non-comparable key. Mention that the unexported key is what forces callers through your accessors.
Show the failure you are preventing — a silent shadow or leak that appears as a wrong value far from the code that set it — and argue for one package owning each key so there is a single place a value is written.
Own the convention across teams: a shared internal package that defines the key types and the accessor pairs, so no service invents its own trace-id key and every reader gets the same defined behaviour when the value is absent.
## What a context value actually is `context.WithValue(parent, key, val)` does not modify anything. It returns a **new** `context.Context` that wraps the parent and holds exactly one key/value pair. `ctx.Value(k)` compares `k` against the pair this node holds and, if it does not match, asks the parent — up the chain to the root, where a miss returns `nil`. The comparison is `==` between two `any` (interface) values. Interface equality compares **both** the dynamic type and the underlying data. That single fact explains the whole convention. ## Why a string key is dangerous There is no registry, no namespacing and no ownership. Every package in the process writes into the same flat key space of one context chain. If your package does: ```go ctx = context.WithValue(ctx, "userID", id) ``` then any other package that writes `"userID"` produces a key that is `==` to yours. Two things can go wrong, both silently: - **Shadowing.** A middleware deeper in the chain stores its own `"userID"` and your lookup now returns theirs — a different type, or the same type with the wrong meaning. - **Leaking.** Code you do not control can read a value you meant to be internal to your package, and start depending on it. Neither produces a compile error, a panic, or a log line. It shows up much later as a wrong value or a failed type assertion. ## The pattern Declare a type only your package can name: ```go type traceIDKey struct{} // zero-size, comparable // or: type ctxKey int; const traceIDKey ctxKey = iota ``` An empty struct is the common choice: it is comparable, allocates nothing, and `traceIDKey{}` written twice inside your package produces two equal keys. An unexported integer-constant type is equally fine and is handy when one package owns several keys. Because the type is unexported, no other package can even write the expression that would build the key, so collision is impossible **by construction** rather than by convention. ## Export accessors, not keys The key being unexported means callers need helpers: ```go func WithTraceID(ctx context.Context, id string) context.Context func TraceID(ctx context.Context) (string, bool) ``` This is a feature. The pair documents what type the value has, gives exactly one place where the value is written, and forces a decision about the missing case at the API level rather than at each call site. ## What `WithValue` requires of a key The key must be non-nil and **comparable**; `WithValue` panics immediately otherwise. A slice, a map or a function value as a key panics rather than failing later. Structs made only of comparable fields are fine. Pointer keys work too — the standard library uses that form, exporting pointer values such as `http.ServerContextKey` when it deliberately wants other packages to read the value — but prefer a pointer to a **non-empty** struct if you take that route, since distinct zero-size allocations are not guaranteed to have distinct addresses. ## Common confusions - *"A package-level `var userKey = \"userID\"` fixes it."* It does not. The value is still a `string`, and the comparison is by type and data, not by which variable it came from. - *"Making the key unique enough is fine."* Uniqueness by hope is not uniqueness. A type is checked by the compiler; a string prefix is checked by nobody. - *"A struct key makes `Value` return a typed result."* It does not. `Value` returns `any` whatever the key is; the caller still asserts. The key protects the namespace, not the value's type — which is exactly why the exported accessor that does the assertion once is worth writing.
- What does context.WithValue require of the key you hand it?It must be non-nil and comparable. `WithValue` checks this and panics on the spot, so a slice, map or function value as a key fails at the call, not later. Empty structs, unexported integer constants and pointers are all comparable and all conventional; the type just has to be one your package alone can name.
- Is an exported context key ever the right choice?Only when you intend other packages to read the value — the standard library does this, exporting pointer keys such as `http.ServerContextKey` so servers and handlers can share state. Even then, prefer exporting an accessor function alongside it, so the value's type and the missing case are documented rather than rediscovered by every caller.
- Would a package-level variable holding the string "userID" solve the collision problem?No. The key's identity is its dynamic type plus its data, not the variable it was read from. Two packages with separate `var` declarations of the same string still produce keys that compare equal. Only a distinct named type — unexported, so nobody else can write it — makes the keys different.
Two libraries writing "ID" on a crate with a marker pen will overwrite each other. A key of your own unexported type is a barcode no other package can print.
saying these in an interview costs you the question
- Says a string key is fine if it is unique enough
- Thinks ctx.Value compares keys by variable identity, not type and value
- Exports the key type so any package can read the value
- Believes context.WithValue detects or rejects duplicate keys
- Uses a slice or map as a key and expects it to work