ASTimplementation

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.

The question

How should I actually represent AST nodes — classes, tagged unions, or indices into an array?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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".

What this phase may assume or do

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.

The same three node kinds, twice
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 it
10 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 operation
17// 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

implementationArena-plus-index is the current mainstream choice in newer frontends — rustc's HIR, Roslyn's green tree, rust-analyzer, Zig's AST (which goes further, to a struct-of-arrays layout) — but it is not universal and not required. Clang has used owning pointers with a placement-new arena since 2007 and is not migrating; GCC's trees are pointer-based garbage-collected objects. The measured wins from indices are real but modest, and are dominated by whether your compiler needs cheap side tables and incremental caching, not by cache locality alone.

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.

Three node encodings, with what each one coststypical
EncodingA child reference isBuysPays
Owning pointersBox<Expr> / unique_ptrObvious ownership, natural recursion, easy to write firstScattered allocation, non-Copy references, lifetimes in every signature, no cheap side tables
Arena + indicesExprId(u32)Copyable ids, cheap side tables, contiguous traversal, trivial serialisation, no lifetimesBounds check per access, ids are untyped unless wrapped, dangling ids alias live nodes instead of trapping
Flattened / SoAimplementationAn index plus a span into a shared child arraySmallest memory footprint, fewest cache lines touched per passConstruction 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.

Desugaring a compound assignment at parse time
Before
a[i()] += 1
After
a[i()] = a[i()] + 1
Legal only when

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.

Illegal when

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 kind tag 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-tree shows the desugared HIR next to -Z unpretty=ast-tree for the surface AST — the diff between them is the desugaring boundary, made visible.
  • Clang: clang -Xclang -ast-dump prints node classes; the class list itself is in clang/AST/Expr.h and reading how many *Expr classes exist is the fastest way to feel a large node-kind count.
  • Measure your own: sizeof the 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, babel and typescript shows 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.

How you represent nodes is an implementation detail nobody outside the parser sees.
It is the interface every pass, every lint rule and every external tool is written against. It is the least private decision in a frontend.
More node kinds means a more precise compiler.
It means a larger surface. Precision comes from the *canonical* encoding being unambiguous, which is as achievable with forty kinds as with four hundred.
Immutable trees are a functional-programming preference.
They are what makes structural sharing, incremental reparsing and concurrent readers possible, which is why the two most editor-focused frontends in production — Roslyn and rust-analyzer — both chose them.

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.

implementationThe claim that modern frontends favour arena indices is drawn from rustc, rust-analyzer, Roslyn, Zig and Carbon as of 2025. Clang and GCC, which together compile most of the world's C and C++, both use pointer-based nodes and show no sign of changing. Treat indices as the default for a *new* frontend that expects to serve tooling, not as a correctness requirement.
typicalExhaustiveness checking on a tagged union is a property of the host language, not of the technique. Rust, OCaml, Swift, Kotlin and modern Java give it to you; C and Go do not — a C 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.
simplifiedThe 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 parent field to every node to simplify one diagnostic. What do you tell them?
  • Should a += 1 be its own AST node? Argue both sides and say what decides it.

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Object layout, allocation strategy and the cost of pointer indirection at runtime
    An 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.