Type Assertions and Reflection Boundaries

Updated

September 13, 2026

Type Assertions and Reflection Boundaries

A type assertion asks an interface for a concrete type. The boring default is the comma-ok form, or a type switch, never a panic. A nil pointer stored in a non-nil interface is a different bug: the interface is not nil, and method calls still run.

Mental model

An interface value is a pair: dynamic type and dynamic value. A bare var c Closer has no type and no value — it is nil. A var d *Drawer that is nil, assigned to c, gives c type *Drawer and value nil. Then c == nil is false.

x.(T)       // panics if x does not hold T
x.(T) ok    // v, ok := x.(T)
switch x := v.(type) { case int: ... }

any is interface{}. Assertions on any are how you recover a type you threw away. Prefer not to throw it away.

Reflection (reflect) can look at types at run time. It exists. It is slow, ugly, and the right tool for things like encoding/json. It is not the tool for routing a ticket. A later chapter treats it as a topic. This chapter stops at the boundary: if you need reflect to call your own methods, the interface is too vague.

Worked examples

Case 1: Comma-ok instead of a panic

Save as describe.go. v.(int) without ok panics on the wrong type. With ok, you get a boolean and a zero int.

// describe.go
package main

import "fmt"

func describe(v any) {
    n, ok := v.(int)
    if !ok {
        fmt.Println("not a table number")
        return
    }
    fmt.Println("table", n)
}

func main() {
    describe(12)
    describe("booth")
}

Run:

go run describe.go

Output:

table 12
not a table number

Use this when you have one expected type. If you have two or three, use a type switch.

Case 2: A type switch at the desk

Save as route_any.go. Each case is a type. x has that type inside the case. default is required if you do not control every caller.

// route_any.go
package main

import "fmt"

func route(v any) {
    switch x := v.(type) {
    case int:
        fmt.Println("table", x)
    case string:
        fmt.Println("note", x)
    default:
        fmt.Println("unknown")
    }
}

func main() {
    route(12)
    route("hold the onions")
    route(true)
}

Run:

go run route_any.go

Output:

table 12
note hold the onions
unknown

A type switch on any is a smell if you built v. Make v an int or a string or a small interface instead. It is reasonable when you are at the edge of a decoder that already lost the type.

Case 3: Assertion without ok panics

Save as bad_assert.go. This is the form to avoid.

// bad_assert.go
package main

import "fmt"

func main() {
    var v any = "soup"
    n := v.(int)
    fmt.Println(n)
}

Run:

go run bad_assert.go

Output (path and offset vary):

panic: interface conversion: interface {} is string, not int

goroutine 1 [running]:
main.main()
    bad_assert.go:8
exit status 2

The panic text is exact enough: interface {} is any. The fix is Case 1.

The trap

A nil *Drawer inside a Closer is not a nil Closer. The nil check in shut does not save you. The method runs. d.Name follows a nil pointer.

Save as typed_nil.go:

// typed_nil.go
package main

import "fmt"

type Drawer struct {
    Name string
}

func (d *Drawer) Close() {
    fmt.Println("closed", d.Name)
}

type Closer interface {
    Close()
}

func shut(c Closer) {
    if c == nil {
        fmt.Println("nothing to close")
        return
    }
    c.Close()
}

func main() {
    var c Closer
    fmt.Println("bare interface nil:", c == nil)

    var d *Drawer
    c = d
    fmt.Println("typed nil in interface:", c == nil)

    shut(c)
}

Run:

go run typed_nil.go

Output (hex and path vary; the two printed lines and the panic message do not):

bare interface nil: true
typed nil in interface: false
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]

goroutine 1 [running]:
main.(*Drawer).Close(...)
    typed_nil.go:11
main.shut(...)
    typed_nil.go:23
main.main()
    typed_nil.go:34
exit status 2

The interface held type *Drawer and value nil. c == nil was false. Close ran.

The fix is to put a true nil interface in, not a typed nil. Check the pointer before it becomes an interface:

// typed_nil_fix.go
package main

import "fmt"

type Drawer struct {
    Name string
}

func (d *Drawer) Close() {
    fmt.Println("closed", d.Name)
}

type Closer interface {
    Close()
}

func shut(c Closer) {
    if c == nil {
        fmt.Println("nothing to close")
        return
    }
    c.Close()
}

func main() {
    var d *Drawer
    if d == nil {
        shut(nil)
        return
    }
    shut(d)
}

Run:

go run typed_nil_fix.go

Output:

nothing to close

shut(nil) is a nil interface. shut(d) when d is a nil *Drawer is not. Returning error has the same trap: var err *MyError; return err is a non-nil error. Return the untyped nil instead: return nil.

Do not “fix” this with reflect. Check the concrete pointer, or do not store it.

The boring rule

  • v, ok := x.(T). Do not assert in a way that can panic unless a panic is the bug report.
  • Type switch for two or three types at a boundary. For your own code, keep the type.
  • Never store a nil pointer in an interface and then test the interface for nil.
  • return nil for a nil error or Closer. Do not return a nil pointer of a concrete type.
  • reflect is for codecs and tools. If you are reflecting a ticket, you wanted a field or a method.

Try this

  1. In describe.go, assert v.(string) with comma-ok as a second branch so "booth" prints as a note.
  2. In route_any.go, add case bool: and print flag. The true call should leave default.
  3. In typed_nil.go, add fmt.Printf("%T\n", c) after c = d. You should see *main.Drawer while c == nil is still false.
  4. Write func wrap() error { var err *Drawer; return err } in a scratch file (Drawer would need to implement error — or use a tiny type E struct{}; func (*E) Error() string { return "x" }). Print wrap() == nil. Then change the body to return nil.