the polymorphism problem
so here's the thing about polymorphism in most languages — you end up building this whole infrastructure just to say "this thing can be one of several types." in Go you define an interface, make sure every type implements it, then do type assertions at runtime and hope you didn't miss one. in Java or C# you build an inheritance hierarchy, maybe throw in a visitor pattern when the hierarchy gets deep, and suddenly you have fifteen files just to represent "an AST node can be a sequence or a choice or a literal."
when i was building the Go version of Pegasus, my PEG parser generator, the AST was an interface. every node type had to implement a node() method that did nothing — it was just there to satisfy the interface constraint. then everywhere i consumed nodes i'd do type switches. it worked but it always felt like i was fighting the language to express something simple.
then i ported to Odin and discovered tagged unions.
one type to rule them all
in Odin a tagged union is exactly what it sounds like — a union where the runtime tracks which variant is currently stored. you define it like this:
AST_Node :: union {
Sequence,
Choice,
Literal,
Char_Class,
Any,
Optional,
Zero_Or_More,
One_Or_More,
Lookahead,
Not_Lookahead,
Rule_Ref,
}that's it. that's the whole type hierarchy. no interfaces, no base classes, no func (n *Sequence) node() {} stubs on every variant. each of those names is just a regular struct defined elsewhere. the union says "an AST_Node is one of these things" and the compiler knows the full set at compile time.
compare this to the Go version where i had:
type Node interface {
node()
}
type Sequence struct { Children []Node }
func (s *Sequence) node() {}
type Choice struct { Alternatives []Node }
func (c *Choice) node() {}
type Literal struct { Value string }
func (l *Literal) node() {}
// ... repeat for every variantevery single type needs that dummy method. and if you forget it, you get a compile error that says something unhelpful about interface satisfaction instead of telling you what you actually did wrong.
pattern matching that actually helps
the real power shows up when you consume the union. in Odin you switch on the value and the compiler knows every possible variant:
compile_node :: proc(node: AST_Node) -> []Instruction {
switch n in node {
case Sequence: return compile_sequence(n)
case Choice: return compile_choice(n)
case Literal: return compile_literal(n)
case Char_Class: return compile_char_class(n)
case Any: return compile_any(n)
case Optional: return compile_optional(n)
case Zero_Or_More: return compile_zero_or_more(n)
case One_Or_More: return compile_one_or_more(n)
case Lookahead: return compile_lookahead(n)
case Not_Lookahead: return compile_not_lookahead(n)
case Rule_Ref: return compile_rule_ref(n)
}
return {}
}n is automatically narrowed to the correct type in each branch. no casting, no type assertions, no n.(*Sequence) with an ok check. it just works.
but the real killer feature is #partial switch. if you only care about some variants you can use a partial switch and the compiler won't complain about missing cases. but if you use a regular switch — which you should for anything exhaustive like a compiler — and then you add a new variant to the union, the compiler immediately tells you every switch statement that needs updating. in Go you'd just get a silent bug where the new type falls through to the default case and you don't find out until runtime.
why this matters more than syntax
this isn't just about saving keystrokes. it's about the compiler understanding your domain model. when the set of variants is closed and known — which it is for an AST, a message type, a token kind, basically anything in a compiler or interpreter — a tagged union encodes that constraint directly in the type system. the compiler can verify exhaustiveness. it can tell you the exact size of the union. it can lay it out in memory without indirection.
in Go every Node is an interface which means it's a pointer plus a type descriptor. that's an indirection on every access. in Odin the union is a value type — it's stored inline, sized to the largest variant plus a tag byte. when you're walking an AST with thousands of nodes that difference adds up.
i think the deeper lesson is that a lot of the patterns we reach for in OOP — interfaces, visitors, double dispatch — exist because the language doesn't have sum types. once you have them, those patterns just dissolve. you don't need a visitor when you have exhaustive pattern matching. you don't need an interface when you have a closed set of variants. you don't need inheritance when each variant is just a struct with its own fields.
tagged unions aren't a feature. they're the absence of a problem.