Designing AST Nodes
Tagged union or class hierarchy, parent pointers or not, pointers or arena indices, forty node kinds or four hundred, and whether `a += 1` is its own node or sugar the parser desugars on the spot. Five decisions, each of which shapes every pass written afterwards.
How should I actually represent AST nodes — classes, tagged unions, or indices into an array?
The AST is still the same tree; this lesson is about what a *node* is made of physically. The choices are: how the kind is discriminated (a tag field, a vtable, or a variant), how children are reached (owning pointers, indices into a node arena, or a flattened child list), what a node knows about its context (a parent pointer, or nothing), and how many distinct kinds exist. The representation question this answers is not "what is the program" but "what can a pass cheaply do to the program".
A node representation is admissible only if every construct the grammar accepts has exactly one canonical encoding, and every pass can distinguish the kinds it cares about without inspecting anything outside the node and its children. The moment two encodings can represent the same program, some pass will handle one and not the other, and the resulting bug is invisible in the source. If sugar is desugared at parse time, the further condition is that the desugared form must be observationally identical to the original — including in evaluation order and in the number of times each subexpression is evaluated.
Key points
- Tagged unions make adding an operation free and adding a node kind loud; class hierarchies do the reverse. Choose based on which change you make more often.
- Arena-plus-index nodes buy copyable ids, cheap side tables, easy serialisation and contiguous traversal; they pay a bounds check, untyped ids and dangling ids that alias instead of crashing.
- A parent pointer makes the tree cyclic, unshareable and hard to version. Thread the parent down the walk, or build a side table on demand.
- Node-kind count is controlled by how much sugar the parser desugars, and desugaring early costs you the ability to talk about the surface form in any later message.
- Two encodings for the same construct is the defect that keeps producing bugs: one pass handles one form, another pass handles the other.
- These decisions are close to irreversible once dozens of passes and any external tooling exist. They are worth an afternoon at the start.
The discrimination question: tagged union or class hierarchy
Every pass begins by asking "what kind of node is this". The two mainstream answers are a tag you switch on, and a virtual method you dispatch through. They look like a language-preference argument and they are not: they trade the two halves of the expression problem against each other, and the choice is close to irreversible once a hundred passes exist.
A tagged union — a kind enum plus a payload, or a Rust/OCaml/Swift enum — makes every pass a single match over all node kinds. The compiler tells you when you have missed one, which is [[exhaustiveness-checking]] doing real work. Adding a node kind breaks every pass at compile time, loudly and correctly. Adding a *pass* costs nothing: it is one more function.
A class hierarchy with virtual methods inverts it. Adding a node kind is a new subclass and nothing else changes, so a language with hundreds of node kinds and a slowly growing pass count is comfortable. Adding an operation means touching every class in the hierarchy — which is precisely why compilers built this way reach for [[visitor-pattern]] almost immediately, to get the tag-switch shape back without giving up the hierarchy.
1// Tagged union: adding Expr::Call breaks every match. That is the point.2enum Expr {3 Number(f64),4 Binary { op: BinOp, lhs: Box<Expr>, rhs: Box<Expr> },5 Ident(Symbol),6}7 8fn type_of(e: &Expr, env: &Env) -> Type {9 match e { // exhaustive; the compiler checks it10 Expr::Number(_) => Type::Num,11 Expr::Ident(s) => env.lookup(*s),12 Expr::Binary { lhs, .. } => type_of(lhs, env),13 }14}15 16// Class hierarchy: adding CallExpr breaks nothing; adding an operation17// means editing NumberExpr, BinaryExpr and IdentExpr by hand.18// abstract class Expr { abstract Type typeOf(Env env); }Neither column is safer in the abstract. What differs is *which change is loud*. Pick the one that makes your frequent change the loud one, because the quiet change is the one that ships broken.
Pointers, or indices into an arena
The obvious encoding of a child is a pointer — Box<Expr>, unique_ptr<Expr>, an object reference. It is also the one that most modern compiler frontends have moved away from. rustc, Roslyn, rust-analyzer, Zig's self-hosted compiler and Carbon all keep nodes in a flat arena and refer to children by a 32-bit index.
The reasons are concrete, and none of them is "indices are more elegant". First, a node reference becomes Copy and four bytes wide: it can be stored in a side table, put in a hash map, sent across a thread boundary, or serialised into a cache file without any of the questions a pointer raises. Second, it removes lifetimes and ownership from every signature in the compiler — a pass takes &Ast and an index, not a borrow of a node with a lifetime that has to be threaded through every helper. Third, the nodes are contiguous, so a traversal walks memory in order instead of chasing pointers across a heap; that is the pointer-chasing problem from Computer Architecture avoided, and spatial locality gained.
The costs are equally concrete. Every child access is now ast.nodes[i], which is a bounds check and a second field access, and the index carries no type information — nothing stops you indexing the expression arena with a statement index, so implementations wrap indices in newtypes to get that back. And a dangling index is *worse* than a dangling pointer: it silently names some other live node instead of crashing.
| Encoding | A child reference is | Buys | Pays |
|---|---|---|---|
| Owning pointers | Box<Expr> / unique_ptr | Obvious ownership, natural recursion, easy to write first | Scattered allocation, non-Copy references, lifetimes in every signature, no cheap side tables |
| Arena + indices | ExprId(u32) | Copyable ids, cheap side tables, contiguous traversal, trivial serialisation, no lifetimes | Bounds check per access, ids are untyped unless wrapped, dangling ids alias live nodes instead of trapping |
| Flattened / SoAimplementation | An index plus a span into a shared child array | Smallest memory footprint, fewest cache lines touched per pass | Construction is fiddly, the tree is hard to mutate at all, debugging output no longer resembles a tree |
Parent pointers: the field you will want and should probably not store
The first time a pass needs to ask "what encloses this node" — to report an error in context, to decide whether a return is inside a function, to rewrite a node in place — the temptation to add a parent field is overwhelming. Resist it once and understand why.
A parent pointer makes the tree cyclic. Cyclic structures cannot be shared between two versions of a tree, cannot be built bottom-up without a fixup pass, cannot be freely copied, and defeat every persistent-data-structure trick that [[ast-transformations]] depends on. In a reference-counted implementation they leak; in an arena they are merely a field that every construction site must remember to set correctly, and one that is wrong the moment any node is reparented.
The standard alternative is to pass the parent chain down the traversal instead of storing it. A visitor already knows its ancestors — they are its call stack. Where random access is genuinely needed, compilers build a parent map as a side table on demand: one pass over the tree, an id -> id map, thrown away when the query is done. Roslyn goes further and computes the parent lazily from an immutable green tree while handing the caller a red-tree wrapper that appears to have parents; the cycle exists only in the view, never in the stored data.
- Need the enclosing function? Thread it down the walk as a parameter.
- Need arbitrary ancestor queries? Build a parent side table once, keyed by node id.
- Need to replace a node in its parent? Return the replacement from the visit and let the parent install it — that is the
[[ast-transformations]]rewrite discipline. - Genuinely need stored parents? Then accept that your tree is mutable, single-version and non-shareable, and design the rest of the frontend around that.
How many kinds, and where the sugar goes
Node-kind count is a real design axis. Too few and every pass carries a pile of conditionals distinguishing cases the type system could have separated — a single Expr node with an op: string field means the type checker cannot know that a + node has two children. Too many and every pass is a four-hundred-arm match, most arms identical, and adding a language feature means editing all of them.
The lever that controls the count is what you do with syntactic sugar. a += 1, for (x of xs), if let, string interpolation, optional chaining — each can be its own node kind, or the parser can desugar it immediately into the core forms. Desugaring at parse time collapses the kind count and means every later pass gets the feature for free, which is a very large saving across thirty passes.
It also destroys the ability to say +=. Every diagnostic, every formatter, every "expand this macro" in an IDE and every refactoring now sees the desugared form. This is why compilers that serve editors keep sugar as distinct nodes in the surface tree and desugar during [[lowering]] into a second, smaller representation — rustc's AST-to-HIR step is exactly this, and the reason it exists at all is that the AST has an audience the HIR does not.
a[i()] += 1
a[i()] = a[i()] + 1
Only if the assignment target is a *place expression whose subexpressions are evaluated exactly once* — in the rewritten form i() appears twice, so the transformation is legal only after binding the target's subexpressions to temporaries first: let t = i(); a[t] = a[t] + 1. It is additionally legal only if the language defines x op= y as exactly x = x op y, which it may not: in C++ a user-defined operator+= is a distinct function from operator+ and may do something entirely different.
When i() has side effects and the naive rewrite calls it twice — the counter increments twice, the log line appears twice, the wrong element is written. Also whenever the language gives the compound operator independent semantics, as C++ and Python (__iadd__, which may mutate in place) both do, in which case the two forms are not the same program at all.
How it works
The steps, in the order the compiler takes them.
- Pick a discrimination scheme: a
kindtag matched exhaustively, or an abstract base class with virtual dispatch. - Decide the child reference: an owning pointer, or an index into a per-kind or shared node arena, with the index wrapped in a newtype so it cannot be confused with another arena's index.
- Decide what a node stores beyond its children: a span always; a type and symbol only if this tree is the annotated one; a parent only if you have accepted mutability.
- Fix the canonical encoding of every surface construct — exactly one node shape per construct — and write it down where pass authors will see it.
- Decide the desugaring boundary: which constructs the parser normalises immediately, and which survive to a separate lowering step that produces a second representation.
- Provide side tables keyed by node id for everything that is not intrinsic to the node — types, symbols, parents, comments — so that the node itself stays small and stable.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A refactoring lands that adds a node kind, and a pass built on virtual dispatch silently inherits a base-class default for it. The feature works everywhere except in that one analysis, and nobody finds out until a user reports that the linter ignores their code.
- Two encodings exist for the same construct — say, a sugar node and its desugared form both reachable — and the optimizer handles one. The same program compiles to different code depending on which syntax was written, and the difference is invisible in review.
- Nodes were desugared at parse time, and the IDE's "rename symbol" rewrites the desugared form back into the file. The user's
for (x of xs)returns as a raw iterator loop and the diff is unreviewable. - A dangling arena index survives a tree edit and now names a different live node. There is no crash: an unrelated expression gets type-checked in place of the intended one, and the error message describes code the user never wrote.
- Parent pointers were added for one diagnostic; two years later incremental reparsing is proposed, and it is not implementable because no subtree can be shared between two versions of the tree.
- The node struct grew a field per pass — a type, a symbol, a constant value, a lint suppression — and the tree no longer fits in cache. Compile times regress by double digits with no single commit to blame.
When it helps
- At the start of a frontend, when the cost of the decision is one afternoon rather than one migration.
- When adding a language feature and deciding whether it is a new node kind or a desugaring — the answer follows directly from whether any *message* needs to mention the surface form.
- When compile time or memory has regressed and the tree is the suspect: node size times node count is a number you can compute and act on.
When it hurts
- Rewriting an established node representation for elegance. The nodes are the compiler's public interface to every pass and every external tool; churn there is expensive out of proportion to the diff.
- Adopting arena indices in a small tool that will never need side tables, serialisation or incremental reparse. You pay the ergonomics and receive nothing.
- Modelling every surface construct as its own node in a compiler with no IDE audience. Four hundred kinds nobody needed is a permanent tax on every pass.
What it costs
Every one of these is paid by something.
- Tagged unions buy exhaustiveness checking and cost a recompile of every pass whenever a node kind is added — in a large frontend that is a genuinely disruptive change, and it is disruptive *by design*.
- Arena indices buy copyable ids, side tables and locality, and cost type safety on the id (a bug becomes a silent alias rather than a segfault) plus an indirection at every child access.
- Storing parent pointers buys O(1) ancestor queries and costs sharing, immutability, incremental reparse and safe bottom-up construction. Almost every frontend that serves an editor concludes the price is too high.
- Desugaring at parse time buys a smaller core and fewer passes, and costs diagnostic quality, formatter fidelity and any refactoring that must round-trip through the surface syntax.
- Adding fields to the node for pass results buys convenient access and costs memory on every node in the program, most of which no pass ever reads — which is why side tables keyed by node id usually win at scale.
What else you could do
What a different compiler or language does instead, and when that is better.
- A red-green tree, as in Roslyn: an immutable, fully-typed, parent-free "green" tree that is shared and cached, plus a lazily-created "red" wrapper that supplies absolute positions and parents on demand. You get parents and immutability at once, and pay a second object model and its allocation traffic.
- A dynamically-typed uniform node — a kind string and a property bag, as many JavaScript tools use for ESTree. Trivially extensible and trivially interoperable across tools; you give up every static guarantee and discover missing cases at runtime.
- Struct-of-arrays, as in Zig: fields stored in parallel arrays so a pass touches only the arrays it reads. The best memory behavior available and by far the least pleasant to construct or debug.
- No AST at all — the single-pass compiler that emits code during parsing. Correct choice for a small embedded language where compile speed dominates and no tooling will ever be written.
See it for yourself
The flag, dump or tool that shows you this directly.
- rustc:
rustc -Z unpretty=hir-treeshows the desugared HIR next to-Z unpretty=ast-treefor the surface AST — the diff between them is the desugaring boundary, made visible. - Clang:
clang -Xclang -ast-dumpprints node classes; the class list itself is inclang/AST/Expr.hand reading how many*Exprclasses exist is the fastest way to feel a large node-kind count. - Measure your own:
sizeofthe node struct and multiply by the node count your parser reports on a large file. Compilers that regressed here almost always did so one field at a time. - JavaScript: astexplorer.net with the parser switched between
acorn,babelandtypescriptshows three different node designs over identical source, including where each one desugars.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Arena indices are just an optimization." The performance is the smaller half. The real payoff is that a node reference becomes a plain copyable integer, which is what makes side tables, caches and incremental compilation tractable.
- "A parent pointer is one field, how bad can it be." It converts the tree into a graph and takes immutability, sharing and incremental reparse with it. It is one field with architectural consequences.
- "Desugaring early is strictly simpler." Simpler for the passes, worse for every message the user reads. The simplicity is real and it is paid for by someone who is not the compiler author.
- "Visitors and tagged unions are alternatives to each other." They are answers to the *same* question from opposite ends of the expression problem, and a compiler often has both — see
[[visitor-pattern]].
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
practical
For a first compiler: tagged union, owning pointers, no parent field, spans on every node, and a side table for anything a pass computes. That gets you exhaustiveness checking and keeps the tree acyclic, and every later migration is possible from there. Add the arena when you first want to cache something keyed by node, which is usually the first time you write an incremental anything.
advanced
The decisions interact. Arena indices make side tables cheap, which makes it attractive to keep types and symbols out of the node, which keeps the node small, which makes an immutable tree affordable to copy, which makes incremental reparse feasible, which is what an editor needs. Owning pointers plus in-node annotations plus parent pointers form the opposite attractor: fast to write, comfortable for a batch compiler, and a dead end for tooling. Neither cluster is wrong; what is wrong is picking one field from each and expecting the properties of both.
internals
Zig's AST takes the arena argument to its conclusion with a struct-of-arrays layout: node tags in one array, main tokens in another, and a two-word data field per node whose interpretation depends on the tag, with variable-length child lists spilled into a shared extra_data array. A pass that only reads tags touches only the tag array, so a traversal that ignores payloads streams through a fraction of the memory. The cost is that constructing or printing a node requires the tag to decode its own data field, debuggers show you integers rather than a tree, and mutation is effectively off the table — which is acceptable precisely because the compiler lowers to a separate IR rather than rewriting the AST.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
switch over an enum warns only with -Wswitch and only when there is no default label, and adding a default arm silently disables the protection you were relying on.a[i()] += 1 desugaring is shown without the temporaries a real implementation inserts, precisely so the double-evaluation bug is visible. Every production compiler binds the place expression's subexpressions to temporaries first; the naive form in the before/after columns is the mistake, not the recipe.If you were asked this in an interview
- You are adding a node kind to a frontend with two hundred passes. What do you want to happen, and which node representation gives it to you?
- Why do several recent compilers refer to AST children by index instead of by pointer? Name a cost of that choice, not just a benefit.
- A colleague wants to add a
parentfield to every node to simplify one diagnostic. What do you tell them? - Should
a += 1be its own AST node? Argue both sides and say what decides it.
Connections
- Programming Languages & Runtime Internals — Object layout, allocation strategy and the cost of pointer indirection at runtimeAn arena is a runtime allocation strategy applied to a compile-time data structure. Why contiguous allocation beats scattered allocation is owned there; what it does to a compiler frontend is owned here.