Struct Tags and reflect

Struct tags are Go’s back door for tooling. Gortex’s declarative routing reads them with reflect to turn a struct into a whole routing table. This post lays the groundwork first.

Reading a struct tag

A struct tag is the raw string after a field, conventionally space-separated key:"value" pairs:

type User struct {
    Name string `json:"name" validate:"required"`
}

The compiler doesn’t interpret it; you read it at runtime with reflect:

f, _ := reflect.TypeOf(User{}).FieldByName("Name")
f.Tag.Get("json")            // "name"
v, ok := f.Tag.Lookup("xml") // "", false

The Get vs Lookup distinction matters: Get returns "" for both “key absent” and “key present but empty”, so it can’t tell them apart; Lookup adds a bool. Gortex detects inject:"" (an empty-value tag) with Lookup, or it would mistake “deliberately empty” for “no tag at all”.

reflect’s trio: Type / Value / Kind

  • reflect.Type: the static structure — field count, names, tags.
  • reflect.Value: the runtime value, readable and writable.
  • Kind: the underlying category (Ptr, Struct, String…), distinct from the named type. type UserID int has Kind Int.
v := reflect.ValueOf(&User{})
v.Kind()         // reflect.Ptr
v.Elem().Kind()  // reflect.Struct; Elem() dereferences one pointer
t := v.Elem().Type()
for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    _ = field.Tag.Get("json")
}

Walking safely, checking signatures

reflect doesn’t return an error on misuse — it panics. Check before you reach:

  • Verify Kind first: don’t call NumField() on a non-struct, or Elem() on a non-pointer.
  • Skip unexported fields with field.CanInterface() (or StructField.IsExported()), or reading the value panics.
  • Before calling a method, get it via MethodByName, inspect Method.Type (NumIn / NumOut) to confirm the signature, then Call.

This is exactly what B2’s route registration does: scan fields for GET / POST methods, check the signature, then register.

The cost of reflection

reflect is slow (it bypasses static dispatch), sidesteps compile-time type checking, and panics at runtime. The rule is one line: use it at startup / config time, keep it out of the hot path. That’s gortex’s split — reflect scans routes at startup for the dev experience, a production build swaps in codegen, and no request touches reflect (see B2, B4).

From Effective Go: blank identifier, Data

The blank identifier _ gets a whole section in Effective Go (The blank identifier): discarding unwanted return values, for range when you only want the index, and the interface check —

var _ io.Writer = (*MyType)(nil) // compile-time proof that *MyType implements io.Writer

Data covers allocation: new(T) returns a *T to zeroed memory, make is reserved for slices / maps / channels. reflect.New is the new equivalent, and B2 uses it to auto-initialise nil handler fields.

Takeaways

  • A struct tag is the raw string after a field; reflect reads it with Get / Lookup, and Lookup distinguishes “absent” from “present but empty”.
  • The trio is Type / Value / Kind; Kind is the underlying category, independent of the named type.
  • reflect panics on misuse: check Kind first, skip unexported fields, verify a method signature before calling.
  • reflect is slow and unsafe: use it at startup, never on the request path.
  • Struct tags + reflect are the groundwork for B2 (Struct-Tag Routing, gortex-struct-tag-routing).

Further reading: the series goes on to dissect the gortex framework built on these ideas — source at yshengliao/gortex.


Outline by Sheng, drafted with Claude · Go 1.24 (latest release at the time) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.