Odin · tagged unions

A union, four levels deep

A union is one chunk of memory, reused to hold any one shape at a time. Putting a Rectangle in it writes over the exact same bytes a Circle would have used — they can't both be there. One extra number at the end remembers which shape is in there right now. Pick a shape and watch which bytes change.

Real bytes, measured from a compiled Odin program (little-endian, f32). Byte index is top-left of each cell.
bytes this shape uses shared slot, idle now tag — which shape padding

Sizes (size_of)

Circle   = 4
Rectangle = 8
Triangle = 12

Shape (union) = 16
 = 12 biggest + 1 tag + 3 pad

What you're seeing

Level 1 was what it is. Now: the case where not using a union is genuinely the wrong call — a renderer's command buffer.

Every frame, a renderer re-records the commands to run, in order — and how many, which kinds, and in what order all change frame to frame as the scene changes. The example below is just one frame; hit "record another frame" and watch it come out a different length and mix. You can't know the count or the types ahead of time — which is exactly why this needs a dynamic array (growable, unknown count) whose element is a union (mixed command kinds): [dynamic]RenderCommand, where RenderCommand :: union { Clear, SetViewport, BindTexture, DrawMesh, DrawText, SetScissor }.

Because every slot is the same size, getting commands[i] is a direct jump: go to the start of the array, step i slots over, you're there — no following a pointer off to somewhere else in memory. That direct jump is the speed of a packed array. And yet the length, the command kinds, and their order are all built fresh at runtime.

The emergent property: because every variant is forced to the same fixed size (Level 1), a different mix of command types every frame still packs into one contiguous array. A union is what lets you have a homogeneous array of heterogeneous things — and a command buffer, whose contents you can't predict, is exactly that. If the sequence were always the same fixed five calls, you wouldn't need any of this — you'd just write the five calls. The union + dynamic array earn their keep because the sequence is different every frame.

Why the alternatives are worse

the OOP version: array of pointers to scattered heap objects
ptr →
ptr →
ptr →
ptr →
ptr →
ptr →
each → a separate heap allocation, somewhere else in memory, with a vtable. the CPU jumps for every command.

Inheritance + virtual dispatch (abstract class Command): one heap allocation per command, a vtable pointer on each, dispatch chases pointers — death by cache miss in a loop that runs thousands of times per frame.

A separate array per command type (all the DrawMeshes here, all the Clears there): now you've lost the order — and order is the whole point (you must set the viewport before you draw into it). This isn't slower, it's broken.

A hand-rolled C struct { enum tag; union payload; }: that is a tagged union — you've just rebuilt Odin's union by hand, without the tag maintained for you and without the exhaustiveness check (Level 4). More code, same idea, more bugs.

Level 2 sold you on the union. Level 3 is the bill: the slot is as big as the biggest variant — every slot, always. One fat variant taxes all the others.

Say you add a sprite command that holds its pixels inline: Sprite :: struct { pixels: [64]u8 }. Now every command in the array — even a tiny Clear — is sized for the sprite. Toggle it:

one slot
a Clear's real need

The fix: keep the variants similar-sized, and push big payloads out of line — store a []u8 slice (a 16-byte pointer+len) that points at the pixels living elsewhere, instead of the 64 bytes inline. The union holds the small handle; the bulk lives in its own buffer. Same data, the slot stays lean, and the array stays cache-friendly. This is the same "size is a worst-case tax paid by everyone" lesson behind data-oriented layout in general.

not the same as #packed Odin's #packed tag is a different kind of "packing": it removes the alignment padding between a struct's fields — e.g. struct #packed { x: u8, y: i32, z: u16, w: u8 } shrinks from 12 → 8 bytes. It does nothing to a union's size. The waste you see above isn't field padding — it's the slot being sized to the biggest variant, and #packed can't change that. Different waste, different tool. (The one that does touch unions, #raw_union, is the opposite of what you want: it's a C-style untagged union — overlapping bytes with no tag at all, so you'd lose the safety from Level 4.)

The last level isn't about bytes — it's a correctness property that emerges once a union has many variants and many places that dispatch on it.

Odin makes a plain switch over a union exhaustive: if you don't handle every variant, the build fails. Not a warning, not a runtime surprise — a compile error, by default:

switch v in cmd {
case Clear:       // ...
case SetViewport: // ...
case DrawMesh:    // ...
}   // forgot DrawText and BindTexture

main.odin(12:2) Unhandled switch case: BindTexture
    Suggestion: Was '#partial switch' wanted?

The emergent payoff: the day you add a new command — say Glow — to the union, the compiler lights up every switch in the codebase that doesn't handle it yet. In a real renderer with dozens of dispatch sites, "did I update everywhere?" stops being a runtime bug hunt and becomes a compile-time to-do list the compiler writes for you. Adding a variant is safe because the type system won't let you forget a spot.

When you genuinely mean to handle only some cases, you opt out explicitly with #partial switch — so the exhaustive default is the safe one, and "I'm being partial on purpose" is the thing you have to say out loud.

That's the arc: L1 one reused chunk + a tag → L2 that sameness lets mixed commands share one flat ordered array → L3 the slot costs the biggest variant, so push bulk out of line → L4 the compiler guards every dispatch, so growth stays safe.

probes reproduce with odin run · sizes & the L4 error are real compiler output