Understanding Go Reflection Fundamentals

Reflection in Go provides the ability to inspect and manipulate program structures at runtime. While most Go code operates on known types determined at compile time, reflection allows programs to work with types that aren’t known until runtime. This capability is the foundation for many of Go’s most powerful libraries and frameworks.

The reflect Package Architecture

At the heart of Go’s reflection system is the reflect package, which provides two fundamental types:

  1. Type: Represents the type of a Go value, providing methods to inspect type characteristics
  2. Value: Represents a runtime value, providing methods to inspect and manipulate it

These two types form the core of Go’s reflection API, with most reflection operations starting by obtaining a reflect.Type or reflect.Value from a variable:

package main

import (
	"fmt"
	"reflect"
)

func main() {
	// Start with a concrete value
	answer := 42
	
	// Get its reflect.Value
	v := reflect.ValueOf(answer)
	
	// Get its reflect.Type (two equivalent ways)
	t1 := v.Type()
	t2 := reflect.TypeOf(answer)
	
	fmt.Printf("Value: %v\n", v)
	fmt.Printf("Type via value: %v\n", t1)
	fmt.Printf("Type direct: %v\n", t2)
	fmt.Printf("Kind: %v\n", v.Kind())
}

Output:

Value: 42
Type via value: int
Type direct: int
Kind: int

The distinction between Type and Kind is crucial: Type represents the specific type (which could be a named type), while Kind represents the underlying base type category (int, struct, map, etc.).

Type Introspection

Type introspection—examining the properties of a type at runtime—is one of reflection’s most common use cases:

package main

import (
	"fmt"
	"reflect"
)

type Person struct {
	Name    string `json:"name" validate:"required"`
	Age     int    `json:"age" validate:"min=0,max=130"`
	Address string `json:"address,omitempty"`
}

func main() {
	p := Person{Name: "Alice", Age: 30}
	t := reflect.TypeOf(p)
	
	fmt.Printf("Type: %v\n", t)
	fmt.Printf("Kind: %v\n", t.Kind())
	fmt.Printf("Number of fields: %d\n", t.NumField())
	
	// Iterate through struct fields
	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		fmt.Printf("\nField #%d:\n", i)
		fmt.Printf("  Name: %s\n", field.Name)
		fmt.Printf("  Type: %v\n", field.Type)
		fmt.Printf("  JSON Tag: %s\n", field.Tag.Get("json"))
		fmt.Printf("  Validate Tag: %s\n", field.Tag.Get("validate"))
	}
}

Output:

Type: main.Person
Kind: struct
Number of fields: 3

Field #0:
  Name: Name
  Type: string
  JSON Tag: name
  Validate Tag: required

Field #1:
  Name: Age
  Type: int
  JSON Tag: age
  Validate Tag: min=0,max=130

Field #2:
  Name: Address
  Type: string
  JSON Tag: address,omitempty
  Validate Tag:

This ability to inspect struct fields and their tags is what powers many Go libraries, including JSON encoders/decoders, ORM mappers, and validation frameworks.

Value Manipulation

Beyond inspection, reflection allows for manipulating values at runtime:

package main

import (
	"fmt"
	"reflect"
)

func main() {
	// Create a new value
	v := reflect.ValueOf(42)
	fmt.Printf("Original: %v (%T)\n", v.Interface(), v.Interface())
	
	// Convert to a different type
	if v.CanConvert(reflect.TypeOf(float64(0))) {
		floatVal := v.Convert(reflect.TypeOf(float64(0)))
		fmt.Printf("Converted: %v (%T)\n", floatVal.Interface(), floatVal.Interface())
	}
	
	// Create and modify a slice
	sliceType := reflect.SliceOf(reflect.TypeOf(""))
	sliceVal := reflect.MakeSlice(sliceType, 3, 5)
	
	// Set values in the slice
	sliceVal.Index(0).SetString("Hello")
	sliceVal.Index(1).SetString("Reflection")
	sliceVal.Index(2).SetString("World")
	
	// Convert back to a concrete type
	strSlice := sliceVal.Interface().([]string)
	fmt.Printf("Created slice: %v\n", strSlice)
}

Output:

Original: 42 (int)
Converted: 42 (float64)
Created slice: [Hello Reflection World]

Reflection Limitations

While powerful, reflection in Go has important limitations:

  1. Performance overhead: Reflection operations are significantly slower than direct operations on known types
  2. Type safety: Reflection bypasses Go’s compile-time type checking, potentially leading to runtime panics
  3. Complexity: Reflection code is often more complex and harder to understand than direct code
  4. Settability: Not all values can be modified through reflection (e.g., unexported fields)

Understanding these limitations is crucial for using reflection effectively. The Go proverb “reflection is never clear” serves as a reminder that reflection should be used judiciously, typically when there’s no reasonable alternative.

package main

import (
	"fmt"
	"reflect"
)

type Example struct {
	exported   int // Unexported field
	Exported   int // Exported field
}

func main() {
	e := Example{exported: 1, Exported: 2}
	v := reflect.ValueOf(e)
	
	// This won't work - we have a copy, not the original struct
	// v.Field(1).SetInt(42)
	
	// We need a pointer to modify values
	v = reflect.ValueOf(&e).Elem()
	
	// Can modify exported fields
	if v.Field(1).CanSet() {
		v.Field(1).SetInt(42)
		fmt.Printf("Modified exported field: %+v\n", e)
	} else {
		fmt.Println("Cannot modify exported field")
	}
	
	// Cannot modify unexported fields
	if v.Field(0).CanSet() {
		v.Field(0).SetInt(99)
	} else {
		fmt.Println("Cannot modify unexported field")
	}
}

Output:

Modified exported field: {exported:1 Exported:42}
Cannot modify unexported field

These fundamentals form the foundation for more advanced reflection techniques. As we’ll see in the next section, combining these basic operations in sophisticated ways enables powerful metaprogramming capabilities that can dramatically reduce boilerplate code and increase flexibility in Go applications.