skip to content

Why is asserting a Go any value to a concrete type cheaper than asserting it to another interface?

level: middleimportance: should knowfreq 35%

answer

  1. one comparison versus one question
  2. how many types can satisfy each target?
  3. the linker knows a concrete type's address
  4. method sets have to be matched somewhere
  5. matched once per pairing, then remembered

basics

~20 s

A concrete target is one comparison: the value's dynamic type against that type's descriptor, whose address is fixed at build time. An interface target makes the runtime match the whole method set, then memoise the answer.

solid answer

~50 s

An interface value carries a pointer identifying its dynamic type plus a data word. Asserting to a concrete type such as `Event` compiles to comparing that type pointer against the address of `Event`'s descriptor, which the linker fixed at build time — an inlined comparison and a branch, no call. Asserting to another interface, say `Handler`, cannot be one comparison, because any number of dynamic types satisfy `Handler`. The runtime has to answer "does this dynamic type have all of `Handler`'s methods?" and produce the method table for that pairing, so the compiler emits a call. The answer is memoised per dynamic-type/interface pair, so the first pairing is the expensive one and later ones are lookups rather than a fresh method-set match. On a per-message dispatch path I keep concrete assertions and push interface-typed ones off the hot path.

code

go · 15 lines
go
type Router interface {
	Route() string
}

func route(ev any) string {
	if e, ok := ev.(OrderCreated); ok {
		// compares the dynamic type against one known descriptor
		return e.Topic
	}
	if r, ok := ev.(Router); ok {
		// asks whether the dynamic type has Router's methods
		return r.Route()
	}
	return "dead-letter"
}

go deeper

for a junior

Recall that an interface value knows the concrete type inside it, and that you can assert either to a concrete type or to another interface. Being able to name both target kinds is enough here.

for a middle

Explain the mechanism: a concrete target is a comparison against one known type descriptor, while an interface target makes the runtime check the whole method set and remember the result per pairing.

for a senior

Connect it to a measurement. Say which assertions can show up under a CPU profile of a per-message dispatcher, and how you would restructure so the implements test happens once instead of per message.

for a principal

Frame it as an API shape decision: routing on behavioural interfaces is extensible and costs a runtime check, routing on concrete types is cheap and closes the set. Decide which the platform's message contract should encourage.

## What the assertion has to decide An interface value is two words: one identifying the dynamic type held inside, one pointing at (or holding) the data. A type assertion asks whether that dynamic type satisfies the target you named. How hard that question is depends entirely on what kind of target you named. ### Concrete target: an identity test `ev.(Event)` can only succeed for exactly one dynamic type. Every type in a Go binary has a single canonical descriptor emitted by the compiler and placed by the linker, so "is the dynamic type `Event`" reduces to comparing the type word in the interface against a known address. The compiler can inline that: load a word, compare with a constant address, branch. There is no call into the runtime on the success path, nothing to search, and nothing to remember afterwards. The same is true of `ev.(*Event)`, which tests for the pointer type's descriptor rather than the struct's — and note those are two different types, so a value boxed as `Event` will not satisfy an assertion to `*Event`. ### Interface target: an implements test `ev.(Handler)` is a different question. Any number of types — including types in packages the compiler never saw together — can satisfy `Handler`. There is no single address to compare against. The runtime must check that the dynamic type's method set contains every method `Handler` declares, with matching names and signatures, and then build the dispatch table (the itab) that pairs those two so that calls through the resulting interface value can find the right function. That is a call, not a comparison. Doing that work on every assertion would be untenable, so the answer is memoised: once a given dynamic type has been matched against a given interface the pairing is kept and later assertions of the same pair find the existing answer instead of re-matching method sets. Recent Go versions also keep a small cache at the assertion site itself, so a call site that keeps seeing the same handful of dynamic types stays cheap. The shape to remember is: **first pairing does real work, subsequent ones do a lookup, and even the lookup is a call where the concrete case was a compare**. ### Where the compiler removes the question entirely Two cases cost nothing worth naming. Converting anything to `any` is not a search at all — the empty interface declares no methods, so every type satisfies it. And when the compiler can already prove the dynamic type at the assertion site (you boxed a known concrete value in the same function and it did not escape into anything opaque), it can fold the check away. ## Why it matters in a dispatcher In a middleware that receives heterogeneous events as `any` and routes them, the assertions are per message. Two shapes look equally idiomatic and are not equally cheap: - routing on concrete event types (`ev.(OrderCreated)`, `ev.(OrderCancelled)`, …) — comparisons; - routing on a behavioural interface (`ev.(Routable)`, `ev.(Validatable)`, …) — implements tests. The second is often the better design and the cost is usually irrelevant, but if a CPU profile of the service shows the dispatch itself, the interface-typed arms are the ones to count, and converting the check into a single method call on a stable interface (decide the type once, dispatch through it many times) removes the repeated question altogether. ## The thing candidates get backwards The common wrong model is that every type assertion is a hash lookup, or that the compiler resolves interface assertions entirely at build time. Neither holds: concrete assertions are compile-time-known comparisons that never enter the runtime, and interface assertions genuinely cannot be fully resolved at build time in the general case, because the dynamic type may come from a package the assertion's package does not import. The other wrong model is symmetrical — that asserting to an interface re-matches method names on every call. It does not; that work happens once per pairing.

  • Does the same split apply to the arms of a type switch?
    Yes. Arms naming concrete types are dynamic-type comparisons, and the compiler is free to dispatch a long run of them better than a linear chain. An arm naming an interface type is an implements test the runtime has to answer, and those arms are evaluated in source order, so an expensive one placed early is paid by every message that falls past it.
  • Is asserting to any expensive?
    No. The empty interface declares no methods, so every type satisfies it and there is nothing to match; the only case that fails is a nil interface value, which has no dynamic type. Assigning to `any` is a conversion the compiler handles directly rather than a search.
  • How would you tell from a measurement that interface-target assertions are the problem?
    A CPU profile of the service that shows runtime frames beneath your dispatch function rather than your own handler code is the signal, since concrete assertions never enter the runtime. Confirm by swapping one arm to a concrete type or hoisting the interface check out of the per-message path and re-profiling under the same traffic mix.

saying these in an interview costs you the question

  • Says every type assertion is a hash-table lookup
  • Claims the compiler resolves interface assertions entirely at build time
  • Thinks method names are re-matched on every interface assertion
  • Assumes concrete and interface targets cost the same
  • Believes a value boxed as Event satisfies an assertion to *Event