Reflection in Practice

Updated

September 13, 2026

Reflection in Practice

The boring default is not to use reflect. Write a method that knows the type. Reach for reflection when you are talking to a format you do not control (a dump, a generic log line) and the set of fields is the point. It is a last resort, not a design style.

Mental model

The reflect package lets a program ask a value what it is at run time: its Type, its Kind (struct, int, pointer, …), and its fields. That is how fmt prints %v for types it has never seen, and how encoding/json walks structs.

The cost is real: no compile-time check that a field exists, panics on unexported fields if you call Interface(), and code that reads like a debugger. If you know the type, you do not need any of it.

Worked examples

Case 1: A tiny field dump for a desk order

Save as dump.go. This is the honest use: print exported fields of a struct you might otherwise pretty-print by hand.

// dump.go
package main

import (
    "fmt"
    "reflect"
)

type Order struct {
    ID     int
    Table  int
    Item   string
    Closed bool
}

func dump(v any) {
    rv := reflect.ValueOf(v)
    if rv.Kind() == reflect.Pointer {
        rv = rv.Elem()
    }
    if rv.Kind() != reflect.Struct {
        fmt.Printf("%v\n", v)
        return
    }
    rt := rv.Type()
    for i := range rt.NumField() {
        f := rt.Field(i)
        fmt.Printf("%s = %v\n", f.Name, rv.Field(i).Interface())
    }
}

func main() {
    dump(Order{ID: 7, Table: 12, Item: "soup", Closed: false})
}

Run:

go run dump.go

Output:

ID = 7
Table = 12
Item = soup
Closed = false

for i := range rt.NumField() is the Go 1.22+ integer range. Each field is looked up by index, not by a string you typed twice.

Case 2: The same dump without reflection

When the type is yours, a method is the whole API. Save as label.go:

// label.go
package main

import "fmt"

type Order struct {
    ID     int
    Table  int
    Item   string
    Closed bool
}

func (o Order) Dump() string {
    return fmt.Sprintf("ID = %d\nTable = %d\nItem = %s\nClosed = %v", o.ID, o.Table, o.Item, o.Closed)
}

func main() {
    fmt.Println(Order{ID: 7, Table: 12, Item: "soup", Closed: false}.Dump())
}

Run:

go run label.go

Output:

ID = 7
Table = 12
Item = soup
Closed = false

This version renames a field at compile time. Case 1 silently skips a field you forgot to care about, or panics if you later unexport it. Prefer Case 2 inside the desk. Keep Case 1 for tools that must accept any.

Case 3: Kinds you actually switch on

Reflection without a kind check is how you panic on a nil pointer. Save as kind.go:

// kind.go
package main

import (
    "fmt"
    "reflect"
)

func kindOf(v any) {
    if v == nil {
        fmt.Println("nil")
        return
    }
    fmt.Printf("%s (%s)\n", reflect.TypeOf(v), reflect.ValueOf(v).Kind())
}

func main() {
    kindOf(7)
    kindOf("soup")
    kindOf(struct{ Table int }{Table: 12})
    var p *int
    kindOf(p)
    kindOf((*int)(nil))
}

Run:

go run kind.go

Output:

int (int)
string (string)
struct { Table int } (struct)
*int (ptr)
*int (ptr)

A typed nil pointer is not the any containing nil. Check both if the value came from an interface.

The trap

Calling Interface() on an unexported field panics. This is not a compiler error. Save as panic_dump.go:

// panic_dump.go
package main

import (
    "fmt"
    "reflect"
)

type order struct {
    id int
}

func main() {
    o := order{id: 7}
    v := reflect.ValueOf(o)
    f := v.Field(0)
    fmt.Println(f.Interface())
}

Run:

go run panic_dump.go

Output (stack trimmed):

panic: reflect.Value.Interface: cannot return value obtained from unexported field or method

The fix is not unsafe. Export the field, or print it from a method on order in the same package (f.Int() still works for an unexported int in the same package, but you are now writing a debugger). The boring fix:

// safe_dump.go
package main

import "fmt"

type order struct {
    id int
}

func (o order) ID() int { return o.id }

func main() {
    fmt.Println(order{id: 7}.ID())
}

Run:

go run safe_dump.go

Output:

7

If you find yourself writing a mini encoding/json for the desk, stop and use encoding/json.

The boring rule

  • Do not design your package around any plus reflect. Design around a type.
  • Use reflect when the type is not known at compile time (fmt-like printers, generic dumps).
  • Always check Kind (and nil) before Elem or Field.
  • Never call Interface() on unexported fields.
  • encoding/json and fmt already walked this path. Import them before you re-implement them.

Try this

  1. In dump.go, pass &Order{ID: 1, Table: 2, Item: "tea"} and confirm the pointer branch still prints fields.
  2. Pass 42 to dump and confirm it takes the non-struct branch.
  3. Add an unexported field note string to Order in dump.go. Run it. Decide whether you wanted that field in the dump. If not, Case 2 was the better API.
  4. Replace the dump with json.MarshalIndent on Order and print the bytes. That is the standard-library version of Case 1.