Reflection

Reflection lets you write code that inspects types — listing fields, iterating over them, generating code based on a struct’s layout. GX’s reflection is fully compile-time, so there’s no runtime overhead and no metadata tables in your binary.

Getting the Number of Fields

@fields(T) returns the number of fields in a struct:

struct Player {
    name:str
    health:i32
    score:f32
    alive:bool
}

fn main() {
    const N = @fields(Player)
    print("Player has {N} fields\n")   // 4
}

The value is computed at compile time — the generated C contains 4 as a literal.

Iterating Over Fields with #for

Use #for with @field(T, i) to generate one block per field:

struct Config {
    port:i32
    host:str
    verbose:bool
}

fn main() {
    var c:Config
    c.port = 8080
    c.host = "localhost"
    c.verbose = true

    #for (i = 0 : @fields(Config) - 1) {
        print("field {i}: {@field(c, i)}\n")
    }
}

The #for loop unrolls at compile time — the compiler generates three separate print calls, one per field, with the field name baked in. No runtime loop, no metadata lookup.

Auto-Generated Serialization

Reflection shines for serialization code — one function handles every struct:

struct Player {
    name:str
    health:i32
    score:f32
}

fn serialize_player(p:*Player) {
    print("{\n")
    #for (i = 0 : @fields(Player) - 1) {
        print("  field: {@field(p, i)}\n")
    }
    print("}\n")
}

fn main() {
    var alice = Player{"Alice", 100, 2500.0}
    serialize_player(&alice)
}

Add or remove a field from Player, and serialize_player automatically handles it — no manual updates.

Reflection + #if for Type-Specific Handling

Combine reflection with compile-time conditionals for per-type logic:

struct Stats {
    strength:i32
    agility:i32
    intelligence:i32
}

fn print_stats(s:*Stats) {
    #for (i = 0 : @fields(Stats) - 1) {
        #if (i == 0) { print("STR: ") }
        #if (i == 1) { print("AGI: ") }
        #if (i == 2) { print("INT: ") }
        print("{@field(s, i)}\n")
    }
}

fn main() {
    var warrior = Stats{85, 60, 40}
    print_stats(&warrior)
}

Reflection in Generic-Looking Code

Without generics, reflection is how you write code that “works for any struct.” Example: a comparison function template:

struct Point2D { x:f32, y:f32 }
struct Point3D { x:f32, y:f32, z:f32 }

fn print_point2d(p:Point2D) {
    print("(")
    #for (i = 0 : @fields(Point2D) - 1) {
        print("{@field(p, i)}")
        #if (i < @fields(Point2D) - 1) { print(", ") }
    }
    print(")\n")
}

fn print_point3d(p:Point3D) {
    print("(")
    #for (i = 0 : @fields(Point3D) - 1) {
        print("{@field(p, i)}")
        #if (i < @fields(Point3D) - 1) { print(", ") }
    }
    print(")\n")
}

fn main() {
    var a = Point2D{1.0, 2.0}
    var b = Point3D{1.0, 2.0, 3.0}
    print_point2d(a)
    print_point3d(b)
}

The functions are separate but the shape of their bodies is identical — only the type changes. Reflection handles the field-by-field work automatically.

Practical Example: Debug Dump

struct GameState {
    level:i32
    player_hp:i32
    enemy_count:i32
    score:f32
}

fn dump_state(s:*GameState) {
    print("=== GameState ===\n")
    #for (i = 0 : @fields(GameState) - 1) {
        print("  [{i}] = {@field(s, i)}\n")
    }
    print("=================\n")
}

fn main() {
    var state = GameState{3, 85, 12, 4250.5}
    dump_state(&state)
}

Output:

=== GameState ===
  [0] = 3
  [1] = 85
  [2] = 12
  [3] = 4250.5
=================

Add a field to GameState, and dump_state picks it up automatically on the next compile.

Try it — Define a struct with 3-4 fields, then write a function that prints each field using #for and @field in the Playground.


Expert Corner

How reflection compiles: @fields(T) is resolved to an integer literal during compilation. @field(value, i) inside a #for loop gets replaced with a direct field access — if i = 0, it becomes value.firstFieldName. The generated C has no reflection machinery whatsoever. There’s no runtime type info table, no RTTI, no name strings stored in the binary.

The index must be a compile-time constant: You can’t do var i = 3; print(@field(p, i)) because the field access is resolved statically. The i must be known at compile time, which means you’ll almost always use reflection inside a #for loop where the loop variable is a compile-time constant.

Why compile-time only: Runtime reflection (like Java or C#) requires metadata tables embedded in the binary — field names, types, offsets, attributes. For a systems language, this overhead isn’t worth it. Compile-time reflection gives you the same generative power without the runtime cost. The tradeoff: you can’t inspect arbitrary types at runtime based on user input.

Comparison to C++ template metaprogramming: C++ templates can inspect types through SFINAE and concepts, but the syntax is arcane and the error messages are legendary. GX’s reflection API is explicit: @fields(T), @field(v, i). That’s it. No complicated type deduction, no “substitution failure is not an error” rules. What you write is what the compiler does.

Comparison to Zig: Zig has @typeInfo(T) which returns a struct with fields like Struct.fields. More powerful (you can inspect types of fields, default values, etc.) but more complex. GX’s version covers 80% of use cases with 20% of the complexity. If you need deeper introspection, you can extend the reflection API — it’s just compile-time AST manipulation in the compiler.

Common patterns that use reflection:

  • Serialization (JSON, binary) — iterate fields, emit each one
  • Debug dump / pretty-printing — show struct contents
  • Field-wise comparison — struct equality without a manual == operator
  • Editor auto-UI — generate form fields for each struct member
  • Testing — generate test cases for every field

Reflection is the GX answer to “how do I do this generically without generics.” The compile-time evaluation keeps it zero-cost.