Unions
A union is like a struct, but all fields share the same memory. Writing to one field overwrites the others. Unions give you low-level control over memory layout — useful for type punning, packed formats, and tagged variants.
Basic Union
Declare with union instead of struct:
union FloatBits {
f:f32
i:u32
}
fn main() {
var x:FloatBits
x.f = 1.5
print("float: {x.f}\n")
print("bits: {x.i}\n") // raw bit pattern of 1.5 as u32
}
Both f and i live at the same memory location. Writing to x.f changes x.i (and vice versa) — you’re viewing the same 4 bytes through different types.
Packed Color
Access individual bytes of a 32-bit color:
struct RGBA {
r:u8
g:u8
b:u8
a:u8
}
union Color {
packed:u32
parts:RGBA
}
fn main() {
var c:Color
c.parts.r = 255
c.parts.g = 128
c.parts.b = 64
c.parts.a = 255
print("packed: {c.packed}\n")
// Read the bytes back
print("r = {c.parts.r}\n")
print("g = {c.parts.g}\n")
print("b = {c.parts.b}\n")
print("a = {c.parts.a}\n")
}
The 4 bytes of parts occupy the same memory as the single u32 packed. Writing the parts lets you read the packed value, or vice versa.
Tagged Union
To store different types safely, pair a union with an enum tag:
enum ValueKind {
Int
Float
Bool
}
union ValueData {
i:i32
f:f32
b:bool
}
struct Value {
kind:ValueKind
data:ValueData
}
fn print_value(v:*Value) {
if (v.kind == ValueKind.Int) {
print("int: {v.data.i}\n")
}
if (v.kind == ValueKind.Float) {
print("float: {v.data.f}\n")
}
if (v.kind == ValueKind.Bool) {
print("bool: {v.data.b}\n")
}
}
fn main() {
var a:Value
a.kind = ValueKind.Int
a.data.i = 42
var b:Value
b.kind = ValueKind.Float
b.data.f = 3.14
var c:Value
c.kind = ValueKind.Bool
c.data.b = true
print_value(&a)
print_value(&b)
print_value(&c)
}
This is how dynamically-typed values, AST nodes, and variant types are built in C — manually tracking which variant is active.
Type Punning
Unions let you reinterpret the bit pattern of one type as another — useful for fast math and custom serialization:
union Convert {
f:f32
i:i32
}
fn main() {
// Fast reciprocal square root trick (Quake III algorithm)
// Not actually faster on modern CPUs, but classic
var c:Convert
c.f = 2.0
print("2.0 as i32 bits: {c.i}\n")
// Back the other way
c.i = 1065353216 // 0x3F800000 = IEEE 754 encoding of 1.0
print("bits back to float: {c.f}\n") // 1.0
}
Union Size
A union’s size is the maximum size of all its fields (rounded up for alignment):
union Small {
a:u8 // 1 byte
b:u16 // 2 bytes
c:u32 // 4 bytes
}
// Small is 4 bytes total — the size of its largest field
This is different from a struct, where fields are laid out sequentially and total size is the sum (plus padding).
Try it — Create a union that lets you access individual bytes of an i32 as a
u8[4]array. Use it to extract the red channel from a packed color in the Playground.
Expert Corner
Union layout in C: GX unions compile directly to C union. No hidden tags, no runtime checks, no allocations — just a block of memory the size of the largest member with all members starting at offset 0. The generated C is typedef union Name { ... } Name;.
Endianness matters: When you access bytes of a larger integer through a union, the order depends on the CPU’s endianness. Little-endian (x86, ARM in common mode) stores the least significant byte first. Big-endian stores the most significant byte first. Most code today runs little-endian, but don’t rely on a specific order for code that must be portable across architectures.
Why tagged unions instead of algebraic data types: Languages like Rust have enum Value { Int(i32), Float(f32) } — the tag and data are bundled automatically. GX’s raw unions require you to manage the tag yourself. The benefit: predictable C layout, no hidden size overhead, direct interop with C code that uses tagged unions. The cost: the compiler can’t enforce that you check the tag before accessing fields.
Extern unions for C interop: When binding to a C library that uses unions (like SDL_Event), declare it extern union Name { ... } — the GX compiler doesn’t emit a definition, just uses the type. This makes the GX code see the exact same layout as the C header.
Type punning and strict aliasing: In standard C, reading a float through an int* pointer is undefined behavior (strict aliasing rule). But accessing through a union is a recognized exception — the compiler treats union members as aliasing each other. GX unions produce C code that’s strict-aliasing safe. Using raw pointer casts to reinterpret types is not.
When to use unions: File format parsing (reading fields of various sizes from a binary blob), tagged values in an interpreter, packed data structures that need to be accessed both as a whole and as pieces, IEEE 754 bit tricks. For most application code, stick with structs — unions are a low-level tool.