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
#forand@fieldin 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.