Viscosity
Review
Part VII · Building Viscosity · Day 24

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.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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.

Definition

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:

Step throughLowering a for-of into an index loop
The for-of loop lowered into five VIR blocks: entry, head with a phi, body, continue and exitb0 entryb1 headb2 bodyb3 contb4 exitb0 %7 = load psb0 %8 = const 0
Lower the array expression first: ps is a binding, so reading it is a load. Then a hidden index variable, keyed forof<offset> so it can never clash with a real name, starts at constant 0.
1/7

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.

Definition

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.

Under the hood

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

ExerciseLower statements to SSA instructions

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 n emits %k = const n
  • [op, a, b] lowers a, then b, 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
  • let and set emit nothing themselves; they record which value the name now holds
  • log emits log %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'],
]));
Extra practiceWhat does this subtree mention?

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, reassigned and assignedInTry decide how each variable is represented.
  • Local, uncaptured variables become SSA values. Captured ones, ones assigned in try, and every array variable become bindings with explicit load/store.
  • for (const p of ps) becomes an index loop: a phi for the index, a length test and an index.get.
  • Unmodelled code becomes opaque, with every mentioned variable as an argument, so the analyses stay sound.

Check your understanding

0/6
  1. 1.

    Which variable does Viscosity lower to SSA values?

  2. 2.

    What does for (const p of ps) p.x += 1; become in VIR?

  3. 3.

    In the for-of lowering, why is the loop head sealed last?

  4. 4.

    const o = { a: 1, ...d }; lowers to opaque(object literal) %2, where %2 is d. Why is d an argument?

  5. 5.

    What does ps[0].x++ lower to after the index.get?

  6. 6.

    When is Math.max(a, b) NOT lowered to an intrinsic?

References

  1. [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. [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. [3] MDN contributors. for...of. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of (checked 2026-10-31)