Type Assertions, Type Switches, and Interface Internals
How Go recovers a concrete type from an interface value, why the comma-ok form exists, and the two-word memory layout that explains the single most surprising bug in the language — the non-nil interface holding a nil pointer.
An interface value in Go is a small, deliberate loss of information. You hand it a *os.File and it gives you back an io.Reader — the concrete type is still in there, but the compiler will only let you call the methods the interface promises. Type assertions and type switches are how you get the concrete type back out, safely and at runtime.
Most engineers learn the syntax in an afternoon and use it for years without ever picturing what an interface value actually is in memory. That’s usually fine — until the day a function returns a value that is != nil and yet panics with a nil-pointer dereference the moment you touch it. That bug is not a mystery once you’ve seen the layout. So this post works from both ends: the surface syntax of assertions and switches, and the two-word representation underneath that makes the syntax — and the traps — make sense.
Type assertions: the two forms and why the second exists
A type assertion x.(T) takes an interface value x and claims “the dynamic type in here is T.” There are two spellings, and the difference between them is the difference between a crash and a branch.
The single-value form is an assertion in the strong sense — you are asserting a fact, and if you’re wrong, the program panics:
var r io.Reader = strings.NewReader("hello")
f := r.(*strings.Reader) // ok: dynamic type really is *strings.Reader
c := r.(*os.File) // panic: interface conversion: io.Reader is *strings.Reader, not *os.File
The comma-ok form turns that same question into a test. Instead of trusting, it reports:
if f, ok := r.(*os.File); ok {
// f is a valid *os.File here
_ = f
} else {
// r held something else; f is the zero value of *os.File (nil)
}
The rule to internalize: use the single-value form only when a failure is a programming error you want to hear about loudly (an invariant you control), and the comma-ok form for anything driven by external data or a value whose dynamic type you’re genuinely unsure of. A panic deep inside a request handler because some upstream value wasn’t the shape you assumed is almost never the failure mode you want.
The gotcha: in the comma-ok form, when ok is false, the first return value is the zero value of T, not left untouched and not the original interface. For a pointer or interface T that’s nil; for a struct T it’s a fully zeroed struct. So v, ok := x.(T) followed by using v without checking ok doesn’t give you “whatever was in there” — it gives you a zero value, silently. Always branch on ok.
Asserting to a concrete type vs. to another interface
The target T in x.(T) can be either a concrete type or another interface, and the two mean genuinely different things.
Assert to a concrete type and you’re asking “is the dynamic type exactly this?” There is no subtyping here — *bytes.Buffer and *strings.Reader both satisfy io.Reader, but r.(*bytes.Buffer) succeeds only when the value is precisely a *bytes.Buffer.
Assert to another interface and you’re asking a different, more interesting question: “does whatever is in here also satisfy this second interface?” This is the standard-library idiom behind a lot of optional-behavior detection:
func writeAll(w io.Writer, chunks [][]byte) error {
// Does this writer also know how to take a string without a []byte copy?
if sw, ok := w.(io.StringWriter); ok {
_, err := sw.WriteString("preamble\n")
if err != nil {
return err
}
}
for _, c := range chunks {
if _, err := w.Write(c); err != nil {
return err
}
}
return nil
}
Here w is typed as io.Writer, but at runtime we probe for the richer io.StringWriter. If the concrete value implements it, we take the faster path; if not, we fall back. The http.ResponseWriter ecosystem does exactly this with http.Flusher, http.Hijacker, and io.ReaderFrom.
The gotcha: interface-to-interface assertions are a runtime feature, so a value can gain and lose capabilities purely by being wrapped. Wrap an *os.File (which implements io.ReaderFrom) in a bufio.Writer or a custom middleware writer that only forwards Write, and w.(io.ReaderFrom) now fails — the optimization silently disappears even though the underlying file still supports it. If you write wrapper types, decide deliberately whether to forward these optional interfaces.
The type switch: one value, many shapes
When you need to branch across several possible dynamic types, a chain of comma-ok assertions is noise. The type switch is the idiom:
func describe(x any) string {
switch v := x.(type) {
case nil:
return "nil interface"
case int:
return fmt.Sprintf("int, doubled = %d", v*2)
case string:
return fmt.Sprintf("string of length %d", len(v))
case io.Reader:
// matches any dynamic type that satisfies io.Reader
return fmt.Sprintf("a reader of type %T", v)
case error:
return "an error: " + v.Error()
default:
return fmt.Sprintf("unhandled type %T", v)
}
}
The special form switch v := x.(type) binds v to the value with the type of the matched case. Inside case int:, v is an int and you can do arithmetic on it; inside case io.Reader:, v is an io.Reader and you can call Read. That per-case retyping is the whole reason the type switch exists — it’s not sugar over a value switch.
A few idioms worth having in muscle memory:
case nilmatches when the interface itself is nil. It’s the clean way to handle “no value” without a separate guard.- Grouping types in one case —
case int, int64:— is legal, but thenvkeeps the interface type (anyhere), not either concrete type, because the compiler can’t pick one. If you need the concrete value, give each type its own case. - Ordering matters when cases overlap. A concrete type and an interface it satisfies can both match; the first matching case wins, top to bottom. Put the specific concrete cases above the broad interface cases.
The gotcha: if you write switch x.(type) without the v := binding, you get the type-dispatch but throw away the retyped value — you’re back to needing an assertion inside each case. Conversely, if you bind v but a case never uses it, go vet won’t complain, but grouped cases like case int, int64: where you then treat v as an int will fail to compile. Bind when you use the value; group only when you don’t need the concrete type.
What an interface value actually is: two words
Everything above is easier to reason about once you know the representation. A non-empty interface value is a pair of machine words:
- a pointer to a type descriptor (an itab for interfaces with methods, described below), and
- a data pointer to the concrete value (or, for values that fit and the compiler chooses, a pointer to a copy on the heap).
Go has two internal shapes for this pair:
eface(“empty interface”) backsany/interface{}. It’s(type, data)— just the concrete type’s descriptor and the data pointer. There are no methods to look up.ifacebacks any interface with methods (io.Reader,error, your own). Its first word is not the raw type descriptor but anitab— a small table that pairs the interface type with the concrete type and caches the concrete method implementations for that interface. Building anitabis what “does this type satisfy this interface” resolves to at runtime; the result is cached so repeated conversions are cheap.
You can picture the two words like this:
| Interface kind | Backing struct | Word 1 (type) | Word 2 (data) |
|---|---|---|---|
any / interface{} |
eface |
*_type (concrete type descriptor) |
pointer to value |
io.Reader, error, custom |
iface |
*itab (interface+type+method table) |
pointer to value |
Two consequences fall straight out of this picture. A type assertion x.(T) to a concrete type is, at heart, a comparison of the type word against T’s descriptor — cheap, a pointer compare. A type switch is the same comparison performed across several candidates. And an interface value equals nil only when both words are zero. Hold on to that last sentence — it is the entire explanation for the next section.
The typed-nil-in-interface trap
Here is the bug that sends people to the debugger. Consider a function that returns error and a helper that returns a concrete error pointer:
type QueryError struct{ msg string }
func (e *QueryError) Error() string { return e.msg }
// BUG: return type is the concrete *QueryError, not the error interface.
func validate(ok bool) *QueryError {
if !ok {
return &QueryError{"validation failed"}
}
return nil // a nil *QueryError
}
func run() error {
return validate(true) // concrete nil is boxed into an error interface here
}
func main() {
err := run()
if err != nil {
fmt.Println("got error:", err) // THIS PRINTS. err is not nil.
} else {
fmt.Println("all good")
}
}
This prints got error: even though validate returned nil. The reason is the two-word layout. When run converts the *QueryError (whose value is nil) into an error interface, the interface’s data word is nil, but its type word is set to *QueryError’s itab. The interface is (type=*QueryError, data=nil) — and since equality with nil requires both words to be zero, err != nil is true. You have a non-nil interface wrapping a nil pointer.
It gets worse: because the type word is populated, a method call like err.Error() will dispatch correctly to (*QueryError).Error with a nil receiver — which is fine only if that method never dereferences e. The moment it does, you get a nil-pointer panic from a value you thought was nil.
The gotcha: the mistake is never at the if err != nil check — that check is doing exactly what the spec says. The mistake is upstream, in returning a concrete pointer type from a function whose logical result is an interface. The nil got a type stapled to it on the way into the interface, and it can never be plain-nil again.
The fix is to make the interface the boundary and never let a typed nil cross it:
// FIX: return the interface directly. A literal `nil` here is a true nil interface.
func validateFixed(ok bool) error {
if !ok {
return &QueryError{"validation failed"}
}
return nil // (type=nil, data=nil) — a genuine nil error
}
Two rules keep you clear of this permanently:
- Functions that can fail should return
error, not a concrete error type. Thenilyou return then boxes to a true nil interface. - If you must hold a concrete error variable and then decide, convert only the non-nil case: check the concrete pointer for nil before assigning it into the interface, or return
nilexplicitly. Neverreturn someConcreteNilPointerwhere the signature sayserror.
You can even confirm the diagnosis at runtime — a comma-ok assertion recovers the typed nil, showing the type word is very much present:
err := run()
if e, ok := err.(*QueryError); ok && e == nil {
fmt.Println("confirmed: non-nil interface wrapping a nil *QueryError")
}
A note on cost
Type assertions and switches are not free, though they’re rarely a bottleneck. Asserting to a concrete type is close to a single pointer comparison. Asserting to an interface type may require constructing (or looking up a cached) itab, which is more work the first time a given (interface, concrete-type) pair is seen. In a hot loop that reflects or asserts across many dynamic types, this shows up. The point here is only awareness: prefer static typing where you can, reach for assertions at genuine boundaries, and if you ever suspect assertion overhead, measure it — a later module in this series covers benchmarking interface dispatch and escape analysis properly, and that’s where the numbers belong.
Key takeaways
- Two assertion forms, two intents.
x.(T)panics on mismatch — use it for invariants you control.v, ok := x.(T)branches — use it for untrusted or uncertain values. On failure,visT’s zero value, not the original. - Assert to an interface to probe optional behavior.
w.(io.StringWriter)asks “does this value also do this?” — the idiom behindhttp.Flusherand friends. Wrappers can silently drop these capabilities. - The type switch retypes per case.
switch v := x.(type)bindsvto the matched type; grouped cases keep the interface type. First matching case wins, so specific before general. - An interface value is two words: a type descriptor and a data pointer.
efaceforany,iface(with an itab) for method interfaces. - An interface is nil only when both words are zero. Returning a concrete nil pointer through an
error-typed slot sets the type word, producing a non-nil interface around a nil pointer. Return the interface type, never a concrete typed nil.
Further reading
- The Go Blog — The Laws of Reflection — the clearest official account of the (type, value) representation of interface values, and the foundation for understanding assertions and reflection.
- The Go Programming Language Specification — Type assertions and Type switches — the normative rules for both forms, including the comma-ok result and nil handling.