AST → Code
Generating stack bytecode from an expression AST is a post-order walk: children first, operator last, one instruction per node. Source 1 + 2 * 3 therefore writes MUL before ADD because * sits deeper in the tree, not because anyone reordered the text. Time complexity is linear in the number of nodes.
Think of a stack of plates. You cannot add until both numbers are sitting there, so the operator is written after its operands. The IR panel — intermediate representation — is the program listing you are writing, not the runtime stack those plates would live on. The plates are why the listing is in that order. The source string is a serialization. The tree is the program.
How AST to code works
The compiler does not copy your source. A parser (the sibling lesson) has already turned the string into a tree of Lit, Name, and BinOp nodes. Codegen walks that tree and appends toy stack bytecode (LOAD_CONST, LOAD_NAME, ADD, MUL) for a later VM to run:
- a literal becomes
LOAD_CONST - a name becomes
LOAD_NAME - a
+or*becomesADDorMUL, after both children have been written
That last rule is the whole lesson. It is why 1 + 2 * 3 and (1 + 2) * 3 — the same tokens, two different trees — emit different programs. Precedence already happened at parse time. Codegen just reads the shape.
You write a pass like this when you ship a transpiler, an expression optimizer, or a VM backend: rewrite the tree first (fold constants, desugar a helper), then reuse the same emitter. Babel and TypeScript both work that way. Fold is that rewrite-then-emit split in miniature.
Fold is a second function that runs first. For each binop, fold the children, and if both came back literals, replace the binop with Lit(eval(op, left, right)). Then emit whatever remains. 1 + 2 * 3 collapses to LOAD_CONST 7. x + 2 * 3 still folds the *, then stops: a name is not a literal, so the + stays and you get three instructions instead of one.
AST → Code Step by Step
The default run is Naive on 1 + 2 * 3. The tree is +(1, *(2, 3)). The IR listing starts empty.
- The program is the tree.
+at the root,1on the left,*on the right with2and3under it. Nothing has been written yet. - Descend into
1. The operator waits. Children first. LOAD_CONST 1. A leaf writes the constant.- Back to
+. The left child is in the IR. - Descend into
*. Still the right child of+. - Descend into
2.*waits the same way+did. LOAD_CONST 2.- Back to
*. - Descend into
3. LOAD_CONST 3.- Back to
*. Both children are in the IR. MUL. Operator last. The source mentioned+first; the IR writesMULfirst, because*was deeper.- Back to
+. ADD. The root emits last.- Five instructions.
LOAD_CONST 1, LOAD_CONST 2, LOAD_CONST 3, MUL, ADD.
Flip the variant to Fold on the same tree. The walk skips leaves (they are already literals), rewrites 2 * 3 into 6, then 1 + 6 into 7, then emits LOAD_CONST 7. Same expression, five instructions become one. The x + 2 * 3 scenario is the breaking point: * still folds, + cannot, and the leftover tree emits LOAD_NAME x / LOAD_CONST 6 / ADD.
The Code panel highlights emit(node.left) and emit(node.right) on the descend beats, and only the LOAD_CONST line on a literal emit — never the LOAD_NAME line at the same time. Fold's return Lit(...) and return BinOp(...) are split the same way.
Why MUL before ADD
Instruction order is tree shape. (1 + 2) * 3 is the same numbers with + deeper, so Naive writes ADD then MUL. Nobody swapped the tokens. The parser built a different tree, and codegen is just walking it.
That is also why Fold is a rewrite, not a special emitter. The emit function is identical in both variants. The only change is the tree it is handed. If Fold rewrote a node before folding the children, 1 + 2 * 3 could not fold the + — the right child would still look like a *, and you would emit three instructions instead of one. Bottom-up is not a style choice. It is the condition the rewrite needs.
Naive cost is O(n) instructions for n nodes. Fold is O(n) to rewrite plus O(k) to emit the remainder, with k ≤ n. Equality holds when nothing folded — a tree like x + y, where no binop has two literal children. A name blocks every ancestor that depends on it, but independent literal subtrees still fold: x + 2 * 3 still folds 2 * 3, so k < n. You pay a linear rewrite for a possibly-shorter program, and you keep whatever the rewrite could not touch.
Edge Cases
- Parentheses change the tree, not the walk.
(1 + 2) * 3is a different shape, so it is a different program. The walk rule does not change. - A name blocks its ancestors, not the cousins. In
(x + 1) * 2neither binop folds;x + 2 * 3still folds2 * 3. - Two independent muls fold separately.
2 * 3 + 4 * 5becomes6 + 20and then26under Fold, or seven instructions under Naive. - A tree of one literal (what Fold leaves of
1 + 2 * 3) emits a singleLOAD_CONST. There is no operator left to wait for children. - There is no empty tree here. The canned expressions all have a root. An empty program is a parser problem.
Common Mistakes
- Emitting in source order. Writing
ADDfirst because the string says1 + ...is pre-order. A stack machine would underflow: the operator would run with an empty stack. - Pretty-printing and codegen are not inverses. Unparsing puts the
+back in the middle of the line. Codegen puts it last, because the consumer is a stack, not a reader. - Folding top-down. If you rewrite the current node before the children have become literals, a constant expression that is not already a flat
Lit op Litwill not collapse. The default tree is the counterexample. - Treating Fold as a different emitter. Both variants share
emit. If the Fold run still writes five instructions, the rewrite did not run, or it ran after emit. - Reading the IR panel as a runtime stack. Those rows are the program being written. The plates metaphor is why operators come last in that listing, not a picture of execution. The stack the VM will use is the next lesson.
A Note on Simplification
This is an explainer, not a compiler backend. The IR is toy stack bytecode (LOAD_CONST, ADD, MUL). Real compilers lower to three-address code or SSA, run many passes (constant propagation, dead-code elimination, inlining), then instruction-select into machine code and allocate registers. There is no control flow here — no if, no loops — and no runtime stack shown.
Post-order emit and one instruction per node belong to this stack target; a source emitter like TypeScript's prints 1 + 2 * 3 infix. The split that stays true is rewrite-then-emit: change the tree, then emit. If you are writing a Babel plugin or a toy VM, you are already on it.
Keep exploring
A few lessons that share the same technique or lead into the next idea.
Premium players need paid access. Every Explain doc is free.
Found a mistake or something unclear? Leave feedback.