Getting the Concrete Value Back
Middlewares store per-request data in a context as any, because the context cannot know every key's type. Reading it back requires asking what is actually inside:
func requestID(ctx context.Context) string {
v := ctx.Value(requestIDKey) // type any — could be anything
id, ok := v.(string)
if !ok {
return ""
}
return id
}
v.(string) is a type assertion: "this interface holds a string — give it to me." The two-value form is the safe one. It never panics; it returns the value plus an ok flag that is false whenever the dynamic type does not match, so a misconfigured middleware degrades to an empty ID instead of taking the request down.
The single-value form has different teeth:
id := v.(string) // panics: interface conversion: interface {} is int, not string
It extracts directly and panics on mismatch — the same class of failure as a bad slice index: your code promised something untrue. Use it only where a mismatch genuinely cannot happen (a value your own code stored and can prove the type of). Everywhere else, comma-ok is the default.
To see what an assertion is really reading, remember the interface value's anatomy from Interfaces and Implicit Satisfaction: a pair of dynamic type plus value. The assertion checks the type half and hands back the value half:
var x any = 42
n := x.(int) // matches: dynamic type is int
s := x.(string) // mismatch: dynamic type is still int, regardless of the value
var buf *bytes.Buffer
var y any = buf
b := y.(*bytes.Buffer) // ok — and b is a nil *bytes.Buffer: type matched, value is nil
That last line separates the two halves cleanly: assertions test types, not values. A nil pointer stored in an interface asserts to its own type perfectly well — the ok flag says nothing about whether the value inside is nil. The empty interface (any) is where assertions are most common, precisely because it promises nothing about its contents; a non-empty interface narrows what can be asserted successfully from the start.
One Switch Instead of Five Ifs
A worker consumes events published as any — each payload a different struct — and routes it to the right handler. Chained assertions read terribly:
if o, ok := payload.(OrderEvent); ok {
handleOrder(o)
} else if p, ok := payload.(PaymentEvent); ok {
handlePayment(p)
} else if s, ok := payload.(ShippedEvent); ok {
handleShipped(s)
}
A type switch states the question once:
switch ev := payload.(type) {
case OrderEvent:
handleOrder(ev) // ev is OrderEvent here
case PaymentEvent:
handlePayment(ev) // ev is PaymentEvent here
case ShippedEvent:
handleShipped(ev) // ev is ShippedEvent here
default:
log.Printf("unknown event %T", payload)
}
The syntax is the giveaway: payload.(type) in the switch head, and ev is re-typed per case — inside case PaymentEvent, the compiler knows ev is a PaymentEvent, so field access needs no further assertion. go vet flags a single-type switch (switch payload.(type) with one case) as something an if would say better; the switch earns its place when there are three or more branches.
Two details of the grammar matter when you read other people's code. Multi-type cases bind ev to the original interface type, because the compiler cannot pick one concrete type:
case OrderEvent, PaymentEvent:
// ev has type any here — no fields accessible
audit(ev) // fine, but narrow the type inside if you need fields
And default is how you make unknown types loud — a missing default silently drops the payload, which is exactly the failure a routing switch exists to prevent. You can also combine the forms: an outer type switch on the broad category, an inner comma-ok assertion when two types share a method set.
One habit from the switch head: log.Printf("%T", payload) is the fastest way to learn an unknown dynamic type — %T prints the dynamic type of any interface value. When an assertion surprises you, print %T before you change the assertion.
Reading an Interface Conversion Panic
When the panicking form fails, the message is a complete diagnosis if you read it literally:
panic: interface conversion: interface {} is *main.User, not main.User
Four failure causes account for nearly every assertion bug in the wild.
Pointer versus value. The message above says exactly what happened: the interface holds *main.User, the code asserted main.User. The type behind an interface is whatever the store put there — a method with a pointer receiver, a constructor returning &User{}, or json.Unmarshal's pointer-widening all leave *T. Match the assertion to what was stored: x.(*User).
Asserting in the wrong direction. An interface can only be asserted to a concrete type (or to a narrower interface its dynamic type satisfies):
var r io.Reader = os.Stdin
sr, ok := r.(io.Reader) // trivially ok — r already is one
But asserting a narrow interface to a wider one fails unless the dynamic type happens to implement the wider set: an io.Reader held in any asserted to io.ReadWriter succeeds only for types that also have Write.
The interface is nil. var x any; _, ok := x.(string) — ok is false, always: there is no dynamic type to match. The panicking form on a nil interface panics with interface conversion: interface is nil, not string. A nil check (if x == nil) precedes any assertion on values that may be absent.
The dynamic type is simply different. json.Unmarshal into any produces float64 for every JSON number, not int — so v.(int) fails on 42 written as JSON. That specific gotcha is why json.Number and Decoder.UseNumber() exist: the stored type is float64 because that is what the decoder stored, and no amount of assertion changes it.
Debugging order that avoids the guessing loop: print the actual type with %T at the failure site, note whether it carries a *, then align the assertion with that — or, if the mismatch is not your code's fault, decide whether the interface's contract should have been narrower so wrong types could not enter in the first place.
Where Assertions Are Allowed to Live
Assertions are a concession: somewhere, a static type was widened to any and now has to be narrowed back. The engineering question is not how to assert — it is where the widening happened and whether it is still justified.
Put the assertion next to the widening. Context values, variadic ...any helpers, registry maps keyed by string, plugin tables — these designs chose to lose type information at one point, so the recovery belongs at the matching read site, guarded with comma-ok. One narrow switch at that boundary is healthy. Assertions scattered through the business logic mean type information was lost earlier than it should have been.
Prefer moving the behavior instead. If a switch keeps testing for OrderEvent versus PaymentEvent to call a different method, the switch is a symptom — the types should carry the behavior:
type Event interface {
Handle(h Handler) error
}
type OrderEvent struct{...}
func (o OrderEvent) Handle(h Handler) error { return h.Order(o) }
// now any consumer works without knowing the concrete types:
event.Handle(handler) // no assertion anywhere
That is the interface lesson applied to the assertion problem: when you control the types, give them a method and let dispatch happen implicitly. Assertions stay necessary where you cannot change the types — foreign payloads, map[string]any from JSON, third-party values.
Errors already have the sanctioned version. errors.As(err, &target) is an assertion specialized for error chains — it walks the wrap chain instead of checking one dynamic type, which is why error handling never uses raw x.(MyError):
var pe *fs.PathError
if errors.As(err, &pe) {
log.Printf("failed path: %s", pe.Path)
}
The full decision table:
| Situation | Tool |
|---|---|
| known exactly one type, mismatch is impossible | single-value x.(T) |
| type uncertain, failure is a normal case | comma-ok v, ok := x.(T) |
| several possible types, each dispatches differently | type switch with default |
| the value is an error | errors.As / errors.Is, never a raw assertion |
| a switch keeps repeating on types you own | move the method onto the types — the assertion disappears |
Used with that discipline, assertions are a boundary mechanism rather than a habit — the place where a deliberately flexible value becomes concrete again, in one spot you can read and test.
Quiz
1. Given `var x any = 42`, what happens with `v, ok := x.(string)`?
2. In `switch ev := payload.(type) { case OrderEvent, PaymentEvent: ... }`, what is the type of ev inside that case?
3. A panic reads: `interface conversion: interface {} is *main.User, not main.User`. What is the correct fix?
4. Your code repeatedly switches over five concrete event types to call a matching handler. What is the stronger fix?
Flashcards
Question
What do the two forms of type assertion do on a mismatch?
Click to reveal answer
Answer
`v, ok := x.(T)` returns the zero value of T and false — never panics, so it fits any uncertain read. `v := x.(T)` panics with an interface conversion message — acceptable only where a mismatch means your own code broke an invariant.
Question
What does a type switch's assigned variable hold in a multi-type case versus a single-type case?
Click to reveal answer
Answer
Single-type case: the variable is that concrete type, so fields are directly accessible. Multi-type case: the variable keeps the switch's original interface type, because the compiler will not pick one concrete type — narrow further inside if you need fields.
Question
Name the four common reasons an interface assertion fails.
Click to reveal answer
Answer
The interface holds a pointer but the assertion names the value type (or vice versa); the assertion widens a narrow interface to a wider one the dynamic type does not satisfy; the interface itself is nil; or the stored type differs from what you assumed — the classic being encoding/json producing float64 for all numbers.
Question
Where should type assertions live in a well-structured program?
Click to reveal answer
Answer
At the boundary where type information was deliberately widened — reading a context value, parsing map[string]any, a registry lookup — guarded with comma-ok. Assertions spread through business logic signal that a narrower interface or a method on the type should have replaced the widening.
Question
Why do error checks use errors.As instead of err.(*MyError)?
Click to reveal answer
Answer
A raw assertion sees only the outermost dynamic type; errors.As walks the whole %w wrap chain looking for a value assignable to *MyError, so a wrapped lower-level error still matches — which is exactly how errors are supposed to behave after wrapping.
Revision Notes
Key Takeaways
- 1. An assertion reads the dynamic type half of an interface pair; comma-ok is the non-panicking default
- 2. A type switch re-types its variable per case; multi-type cases fall back to the original interface type
- 3. Most assertion bugs are pointer-versus-value or wrong-stored-type — print %T before guessing
- 4. Assertions belong where widening happened (context, map[string]any), not sprinkled through logic
- 5. When you own the types, move the behavior onto them with a method and the switch disappears
Interview Tips
- • "Difference between `x.(T)` and `v, ok := x.(T)`?" — panicking versus comma-ok; give the context-value middleware as the place you always use the safe form
- • "Walk through a type switch" — `switch t := x.(type)`, per-case typing, multi-case caveat, and why default should be loud
- • "You got an interface conversion panic — first step?" — read the message's stored type, print %T, check for the pointer prefix; shows a debugging process, not trivia
- • "When would you avoid assertions entirely?" — when you control the types: give them a method and dispatch through the interface; for errors, errors.As walks the wrap chain
Cheat Sheet
Type Assertions and Type Switches
Choose the form:
| Situation | Form | Behavior |
|---|---|---|
| type certain | x.(T) |
panics on mismatch |
| type uncertain | v, ok := x.(T) |
ok=false, zero value |
| 2+ possible types | switch t := x.(type) |
t re-typed per case, default required for loudness |
| error chain | errors.As(err, &target) |
walks %w chain, never a raw assertion |
Debug order: print %T on the value -> check for a leading * -> align the assertion to the stored type.
Failure checklist: pointer vs value mismatch | widening narrow->wide interface | nil interface | stored type differs (json numbers = float64).
Code template:
switch ev := payload.(type) {
case OrderEvent:
return handleOrder(ev)
case PaymentEvent:
return handlePayment(ev)
default:
return fmt.Errorf("unknown event %T", payload)
}
Continue Learning
Before You Start
- No prerequisites listed.