Reflection in Practice
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput (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.goOutput:
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
anyplus 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) beforeElemorField. - Never call
Interface()on unexported fields. encoding/jsonandfmtalready walked this path. Import them before you re-implement them.
Try this
- In
dump.go, pass&Order{ID: 1, Table: 2, Item: "tea"}and confirm the pointer branch still prints fields. - Pass
42todumpand confirm it takes the non-struct branch. - Add an unexported field
note stringtoOrderindump.go. Run it. Decide whether you wanted that field in the dump. If not, Case 2 was the better API. - Replace the dump with
json.MarshalIndentonOrderand print the bytes. That is the standard-library version of Case 1.