Go Advanced #4 reflect Package — Handling Types at Runtime

5 min read

After #3 Generics, this time another flavor of dynamic tool. reflect.

reflect is a tool for inspecting and manipulating types at runtime. You will rarely reach for it directly, but it appears inside libraries like encoding/json, text/template, and ORMs. Understanding how it works makes those libraries make sense.

Two basic concepts — Type and Value #

getting started with reflect
import "reflect"

func main() {
	x := 42

	t := reflect.TypeOf(x)
	v := reflect.ValueOf(x)

	fmt.Println(t)         // int
	fmt.Println(v)         // 42
	fmt.Println(t.Kind())  // int
}
  • reflect.Type — type metadata (name, kind, fields, methods, etc.)
  • reflect.Value — the value itself plus type info

Kind — broad type category #

v := reflect.ValueOf(struct{ X int }{42})
fmt.Println(v.Kind())    // struct

Kind is a broad category (int, string, struct, slice, map, ptr, etc.), while Type is more specific — for example, a defined type like mypkg.User.

Inspecting struct fields #

iterating struct fields
type User struct {
	Name  string
	Age   int
	Email string
}

u := User{"Dokyung Lee", 30, "x@y.z"}
v := reflect.ValueOf(u)
t := v.Type()

for i := 0; i < t.NumField(); i++ {
	field := t.Field(i)
	value := v.Field(i)
	fmt.Printf("%s %v = %v\n", field.Name, field.Type, value)
}
// Name string = Dokyung Lee
// Age int = 30
// Email string = x@y.z

t.NumField(), t.Field(i), v.Field(i) — exactly what a JSON encoder does internally.

Reading struct tags #

struct tags
type User struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

t := reflect.TypeOf(User{})
field := t.Field(0)
fmt.Println(field.Tag.Get("json"))    // name

Field(i).Tag.Get("json") — extracts only the json:"..." portion. encoding/json decides how to serialize using this info.

This mechanism is what lets Go libraries accomplish so much by inspecting struct definitions alone.

Modifying values — the Value must be settable #

modifying values
x := 42
v := reflect.ValueOf(&x).Elem()    // pointer → the value it points to
v.SetInt(100)
fmt.Println(x)                      // 100

Two key points:

  • Pass a pointer to ValueOf, then get the pointed-to value with Elem()
  • That value must be settable to call SetInt, SetString, etc.
checking settable
x := 42
v := reflect.ValueOf(x)         // value copy
v.CanSet()                       // false — copy can't be modified

v = reflect.ValueOf(&x).Elem()
v.CanSet()                       // true

Dynamic function calls #

calling a function via reflect
func add(a, b int) int { return a + b }

f := reflect.ValueOf(add)
results := f.Call([]reflect.Value{
	reflect.ValueOf(1),
	reflect.ValueOf(2),
})
fmt.Println(results[0].Int())    // 3

Call lets you invoke a function at runtime. It is used in RPC libraries and handler dispatch.

Interface conversion — dynamic version of type assertion #

reflect and interface
var i interface{} = "hello"

v := reflect.ValueOf(i)
fmt.Println(v.Kind())     // string
fmt.Println(v.String())   // hello

What is inside an interface{} can be inspected without a type assertion.

Typical example — mini JSON encoding #

Once you understand it, reflect’s role becomes clear. Let’s build a simplified version in a few lines.

simple serialization
func toMap(v any) map[string]any {
	rv := reflect.ValueOf(v)
	rt := rv.Type()

	m := map[string]any{}
	for i := 0; i < rt.NumField(); i++ {
		field := rt.Field(i)
		key := field.Tag.Get("json")
		if key == "" {
			key = field.Name
		}
		m[key] = rv.Field(i).Interface()
	}
	return m
}

type User struct {
	Name string `json:"name"`
	Age  int
}

toMap(User{"Dokyung Lee", 30})
// map[name:Dokyung Lee Age:30]

encoding/json does essentially the same — more thoroughly (zero value handling, omitempty, nested structs, slices, etc.).

Common case — automatic env variable mapping #

auto env mapping
type Config struct {
	Port int    `env:"PORT"`
	Host string `env:"HOST"`
}

func loadEnv(cfg any) {
	v := reflect.ValueOf(cfg).Elem()
	t := v.Type()

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		key := field.Tag.Get("env")
		val := os.Getenv(key)
		if val == "" {
			continue
		}

		fv := v.Field(i)
		switch fv.Kind() {
		case reflect.String:
			fv.SetString(val)
		case reflect.Int:
			n, _ := strconv.Atoi(val)
			fv.SetInt(int64(n))
		}
	}
}

This pattern is the core of libraries like caarlos0/env and kelseyhightower/envconfig.

Pitfall — reflect is slow #

reflect is tens of times slower than ordinary code. The reasons:

  • Type checks happen at runtime
  • More memory allocations
  • Compiler can’t optimize
benchmark example
BenchmarkDirect-8       1000000000      0.3 ns/op
BenchmarkReflect-8        50000000       30 ns/op

In hot paths — consider code generation instead of reflect (next #7). Or, interface + type switch is often faster than reflect.

Pitfall — compiler help disappears #

v.SetString("hi")    // if v is int → runtime panic

reflect bypasses static type checking, so misuse becomes a runtime panic rather than a compile-time error. Always check Kind() first to be safe.

Pitfall — interface{}’s peculiar nil #

common pitfall
var p *User = nil
var i interface{} = p

i == nil    // false — interface is a (type, value) pair
v := reflect.ValueOf(i)
v.IsNil()   // true — Value.IsNil looks at the inner value

For an interface to be nil, both its type and value must be nil. reflect.Value.IsNil() tells you whether the inner pointer is nil (depending on Kind).

Relation to unsafe.Pointer #

reflect internally uses unsafe.Pointer — it is safe by design, but misuse can recreate the same risks. Generally only library authors go deep; most users stay on the surface API.

#5 unsafe and cgo covers unsafe.

When should you use it? — avoid if you can #

reflect’s principle:

  • If compile-time information is enough → generics, interfaces, code generation
  • For libraries that must work without knowing the type → reflect fits

Most business logic falls in the first bucket. Direct reflect usage is more common in library and framework code.

“Reflection is never clear.” — Go proverbs

Practice — fmt.Println is also a reflect user #

The reason fmt.Printf("%v", x) can print arbitrary types is reflect. It is a representative use case in the standard library.

fmt.Sprintf("%v", User{"Dokyung Lee", 30})    // {Dokyung Lee 30}

Wrap-up #

What we covered:

  • reflect.Type — type metadata, reflect.Value — value + type
  • Kind — broad type category (int, struct, ptr, etc.)
  • Iterating struct fields/tagsNumField, Field, Tag.Get
  • Modifying values — pointer → Elem() → check settable → Set*
  • Dynamic callreflect.Value.Call
  • Slow — avoid in hot paths; if hot, use code generation
  • Bypasses static typing — runtime panic risk
  • Prefer generics/interfaces when possible

In the next post (#5 unsafe and cgo) — two tools that step outside Go’s safe zone. Rarely used, but knowing what calls for them is worth it.

X