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.
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 }.
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.
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:
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.
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.