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:

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:

  1. a pointer to a type descriptor (an itab for interfaces with methods, described below), and
  2. 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:

You can picture the two words like this:

What an interface value actually is: two words
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:

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


Further reading