Skip to content
intermediate Phase 4 · Structs, Methods, and Interfaces

Type Assertions and Type Switches

Extract concrete values from interface values safely with comma-ok assertions.

35m
0 problems
Topic Progress 0%

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)`?

Question 1 options

2. In `switch ev := payload.(type) { case OrderEvent, PaymentEvent: ... }`, what is the type of ev inside that case?

Question 2 options

3. A panic reads: `interface conversion: interface {} is *main.User, not main.User`. What is the correct fix?

Question 3 options

4. Your code repeatedly switches over five concrete event types to call a matching handler. What is the stronger fix?

Question 4 options

Flashcards

Question

What do the two forms of type assertion do on a mismatch?

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?

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.

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?

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)?

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.

Related Reading