93. Lowering JavaScript to VIR
The parser hands you a tree shaped like the source. The analyses want a graph of blocks and values. This chapter walks the real front-end that turns one into the other, and the one trick that lets it stay safe without understanding all of JavaScript.
- Explain why lowering resolves scopes first, and what usedIn, reassigned and assignedInTry record.
- Decide, for any JavaScript variable, whether Viscosity lowers it to SSA values or to an explicit binding.
- Trace how a for-of loop becomes an index loop with a phi in VIR.
- Explain how the opaque instruction, with every mentioned variable as an argument, keeps the front-end sound.
From a tree to a graph
Chapter 92 left you with an ESTree: a ForOfStatement holding a
VariableDeclaration, an Identifier and an ExpressionStatement.
That tree mirrors how the code was written. The analyses in the
next chapters ask questions about how it runs: which values flow
where, which loop reads which field, what can reach a call. Those
questions need VIR, the SSA graph from chapter 91.
The front-end walks the tree once and builds that graph, in
frontend-js/src/lower.ts, about 600 lines.
A recipe says “cream the butter and sugar, then fold in the flour”. A kitchen robot needs “move arm to bowl 1, rotate 40 times, …”. The recipe groups words the way people think. The robot’s list orders actions the way they happen. Lowering rewrites the first kind of text into the second.
AST-to-IR translation walks a syntax tree and emits IR instructions for each node: an expression returns the value that holds its result, a statement adds instructions and blocks. Control structures like loops become explicit blocks and branches, and variables become SSA values or explicit memory.
First, find out what every name means
Before emitting anything, resolve.ts walks the tree with a stack of
scopes, as your Drip resolver did in chapter 42. It maps every
Identifier node to the binding it refers to, and records three
facts per binding:
interface JsBinding {
name: string;
kind: 'const' | 'let' | 'var'
| 'param' | 'function' | /* … */;
owner: Node; // function that declares it
usedIn: Set<Node>; // functions using it
reassigned: boolean;
assignedInTry: boolean;
init: Node | null;
}
usedIn answers “is this captured?”: if any function in the set is
not the owner, a closure reads or writes it. assignedInTry is set
when an assignment sits inside a try block.
SSA value or binding?
Then comes the most important decision in the file. For each binding,
lower.ts picks one of two representations (simplified):
const simple = kind is const, let, var,
param or catch;
if (simple && !capturedElsewhere
&& !b.assignedInTry
&& !isArrayInit(b.init))
ssa.add(b);
An SSA variable is never stored anywhere. Writing it just records
“s is now %8”, and reading it asks Braun’s algorithm (chapter 78)
for the current value, building phis at joins. Everything else is a
binding, read and written with explicit load and store
instructions. Here is the real VIR for a short program, from
printFunc:
let s = 0;
let n = 0;
for (let i = 0; i < 3; i++) s = s + i;
const f = () => n;
b0:
%0 = const 0
%1 = const 0
store n, %1
%3 = const 0
br b1
b1: ; preds b0 b3
%7 = phi %0, %8
%4 = phi %3, %11
…
b2: ; preds b1
%8 = binop + %7, %4
s and i vanished into values and phis. n is read by the arrow
function, so it stays a binding with a real store. Captured
variables must be bindings: the closure may read them at any time,
long after any SSA value was current.
Two rules look odd at first. A variable assigned in a try is a
binding because the lowering doesn’t model the edges an exception
can take; with SSA, a catch block could see the wrong value. And
a variable initialized with an array, such as const ps = [], is
always a binding, even when it never changes. That is deliberate:
the collection analysis in chapter 96 finds collections by scanning
stores and loads, so every read of ps must show up as a load.
Expressions return values
The heart of the file is expr(e), one switch over ESTree node
types. Each case lowers its children and returns the ValueId of
the result. The cases that matter most for layouts make VIR’s
first-class record and collection nodes:
case 'ObjectExpression':
// plain { a: 1, b: x } only
return fb.emit('record.new', values,
{ fields });
case 'ArrayExpression':
if (e.elements.length === 0)
return fb.emit('array.empty', []);
return fb.emit('array.new', elems);
An object literal becomes record.new only if every property is a
plain key: value with a name or string key, no computed keys, no
methods, getters or spreads, and no duplicates. A call
xs.push(v) becomes a push node, and Math.sqrt(x) an
intrinsic, but only if Math resolves to the global (a local
variable called Math could be anything). Other method calls
become a field.get and a call with the receiver.
Assignments go through two helpers. assignTo handles =.
readTarget handles += and ++: it lowers the object (and index)
once, then returns a get and a set that share them, just as
JavaScript evaluates p.x once in p.x += 1. For ps[0].x++ the
real VIR is:
%13 = index.get %11, %12
%14 = field.get %13 .x
%15 = unop + %14
%16 = const 1
%17 = binop + %15, %16
field.set %13, %17 .x
The unop + is JavaScript’s ToNumber: x++ returns the old value
as a number, even if x held the string "5".
Loops become blocks
Statements add blocks. A while or for loop gets four: head,
body, continue and exit, the targets of continue and break.
Before any statement, function declarations are hoisted: each is
lowered into its own VIR function, and a closure is stored in its
binding at the very top, so calling sq(3) above
function sq(n) {…} works.
The interesting loop is for (const p of ps). In JavaScript it runs
the iterator protocol[3]. When the right side is
a plain identifier, Viscosity lowers it to an index loop instead.
For arrays that visits the same elements in the same order (as long
as nobody replaces the built-in array iterator), and it
makes element i an ordinary index.get that the analyses already
understand. Step through it, with the real VIR:
If the right side is anything else, say for (const p of make()),
the test and the element both become opaque, and no analysis can
claim to know what p is.
Opaque: the escape hatch that keeps it sound
The front-end does not understand classes, template literals,
destructuring, delete, optional chains, regular expressions or
spread arguments. It doesn’t need to. Each of them becomes one
instruction:
const opaque = (n, note) =>
fb.emit('opaque', mentioned(n),
{ note, src: src(n) });
mentioned(n) walks the subtree and reads every variable it names:
not property names, not object keys, and not globals. A spread makes
an object literal opaque, so this is the real VIR for one:
let t = 0;
const d = Math.sqrt(t * t);
const o = { a: 1, ...d };
%0 = const 0
%1 = binop * %0, %0
%2 = Math.sqrt(%1)
%3 = opaque(object literal) %2
The 1 isn’t even lowered, but d (that is, %2) is an argument:
whatever the spread does, it might do it to d.
An opaque instruction stands for code the IR doesn’t model. Its arguments are every local variable the code mentions, and its result is unknown. The analyses treat each argument as escaping, the most pessimistic answer, so leaving something out of the model can make Viscosity refuse a transform but never make it break your program.
This is how a 600-line front-end can face all of JavaScript. Missing a construct costs performance, not correctness, as long as nothing is ever dropped silently.
The builder behind fb.readVariable, fb.writeVariable and
fb.seal is Braun et al.’s on-the-fly SSA construction, which works
during a single walk of the tree: blocks are sealed as soon as their
predecessors are known[1]. That is why the
for-of lowering seals the body right after the test, but the head
only after the back edge exists. The input is the standard ESTree
node set[2], so the same lowering runs on acorn’s
tree and on the book’s hand-written parser.
Your turn
Write lower(stmts). A statement is ['let', name, e], ['set', name, e] or ['log', e]. An expression is a number, a variable name, or [op, a, b]. Return a list of instruction strings, numbering values from %0:
- a number
nemits%k = const n [op, a, b]lowersa, thenb, then emits%k = binop op %a, %b- a variable name emits nothing: it is SSA, so it is just the value it was last given
letandsetemit nothing themselves; they record which value the name now holdslogemitslog %v
Throw an Error for a name that was never declared with let.
function lower(stmts) {
const out = [];
// your code here
return out;
}
console.log(lower([
['let', 'a', 1],
['set', 'a', ['+', 'a', 2]],
['log', 'a'],
]));Write mentioned(node) for ESTree-like nodes. Return the names of every Identifier in the subtree, each once, in the order first seen. But skip names that are not variables: the property of a non-computed MemberExpression (p.x mentions p, not x) and the key of a non-computed Property ({ a: v } mentions v). Computed ones (p[i], { [k]: v }) mention both sides.
function mentioned(node) {
const out = [];
function visit(n) {
if (!n) return;
if (typeof n !== 'object') return;
if (Array.isArray(n)) {
n.forEach(visit);
return;
}
if (n.type === 'Identifier') {
out.push(n.name);
return;
}
for (const k in n) visit(n[k]);
}
visit(node);
return out;
}What you learned
- AST-to-IR translation turns each expression into a value and each statement into instructions and blocks.
- Lowering resolves scopes first:
usedIn,reassignedandassignedInTrydecide how each variable is represented. - Local, uncaptured variables become SSA values. Captured ones,
ones assigned in
try, and every array variable become bindings with explicitload/store. for (const p of ps)becomes an index loop: a phi for the index, alengthtest and anindex.get.- Unmodelled code becomes opaque, with every mentioned variable as an argument, so the analyses stay sound.
Check your understanding
0/6- 1.
Which variable does Viscosity lower to SSA values?
- 2.
What does
for (const p of ps) p.x += 1;become in VIR? - 3.
In the for-of lowering, why is the loop head sealed last?
- 4.
const o = { a: 1, ...d };lowers toopaque(object literal) %2, where%2isd. Why isdan argument? - 5.
What does
ps[0].x++lower to after theindex.get? - 6.
When is
Math.max(a, b)NOT lowered to an intrinsic?
References
- [1] Matthias Braun, Sebastian Buchwald, Sebastian Hack, Roland Leißa, Christoph Mallon, Andreas Zwinkau (2013). Simple and Efficient Construction of Static Single Assignment Form. Compiler Construction (CC 2013), LNCS 7791, Springer, pp. 102–122. doi:10.1007/978-3-642-37051-9_6 (checked 2026-10-31)
- [2] ESTree contributors. The ESTree Spec (es5.md: Node objects). github.com/estree/estree. github.com/estree/estree/blob/master/es5.md (checked 2026-10-31)
- [3] MDN contributors. for...of. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of (checked 2026-10-31)