Using Folded Expressions in WAT

This page answers one question: the WebAssembly text format lets you write instructions either as a flat list or as nested S-expressions — how do the two forms relate, and when should you use each?

Prerequisites

Two notations for one instruction sequence

The binary format stores a function body as a flat sequence of instructions that manipulate the operand stack. The text format can write that sequence directly — one instruction per line, in execution order — which is the flat or linear form. It can also write it as nested folded expressions, where an instruction is written in parentheses with its operands inside it: (i32.add (local.get $a) (i32.const 1)).

Folding is pure syntax. The assembler unfolds a folded expression by emitting its operands first, left to right and depth first, and then the instruction itself. So (i32.add (local.get $a) (i32.const 1)) assembles to exactly the same bytes as local.get $a, i32.const 1, i32.add. Nothing about meaning or performance changes; only readability does.

The same computation written flat and folded The flat form lists instructions in stack order, one per line. The folded form nests each instruction around its operands, which reads like an expression tree. Both assemble to identical bytes. flat local.get $x local.get $x f64.mul local.get $y local.get $y f64.mul f64.add f64.sqrt execution order, easy to trace folded (f64.sqrt (f64.add (f64.mul (local.get $x) (local.get $x)) (f64.mul (local.get $y) (local.get $y)))) expression shape, easy to read

Step 1 — write arithmetic folded

Folded form shines for arithmetic, where the nesting matches how the formula is written on paper:

;; area of a triangle from three vertices: |(x2-x1)(y3-y1) - (x3-x1)(y2-y1)| / 2
(func (export "tri_area") (param $x1 f64) (param $y1 f64) (param $x2 f64) (param $y2 f64)
                          (param $x3 f64) (param $y3 f64) (result f64)
  (f64.div
    (f64.abs
      (f64.sub
        (f64.mul (f64.sub (local.get $x2) (local.get $x1)) (f64.sub (local.get $y3) (local.get $y1)))
        (f64.mul (f64.sub (local.get $x3) (local.get $x1)) (f64.sub (local.get $y2) (local.get $y1)))))
    (f64.const 2)))

Written flat, the same function is nineteen lines of local.get and arithmetic that you have to simulate on a mental stack to understand.

Step 2 — see what it unfolds to

wat2wasm area.wat -o area.wasm
wasm2wat area.wasm | sed -n '/tri_area/,/^  )/p'
(func (;0;) (type 0) (param f64 f64 f64 f64 f64 f64) (result f64)
  local.get 2
  local.get 0
  f64.sub
  local.get 5
  local.get 1
  f64.sub
  f64.mul
  ...

The disassembler prints flat form by default, which is why compiled modules look nothing like hand-written folded code. Pass -f to wasm2wat to fold where possible, as shown in converting Wasm back to WAT with wasm2wat.

Step 3 — mix the two styles

Folded and flat instructions can be mixed freely in one function, because a folded expression simply pushes its result like any flat instruction. A common style is to fold arithmetic and keep statement-like instructions flat:

(func $step (param $p i32)
  ;; load position and velocity, add, store back
  (f32.store offset=0 (local.get $p)
    (f32.add (f32.load offset=0 (local.get $p))
             (f32.load offset=8 (local.get $p))))
  local.get $p
  i32.const 1
  call $mark_dirty)

Mixing is also how you write code where a value stays on the stack across several statements — something folded form cannot express, because a folded expression must contain all of its operands.

Step 4 — fold control constructs

block, loop and if have folded forms too. The folded if puts its condition first and its arms in then and else clauses, which reads much like ordinary code:

(if (result i32) (i32.gt_s (local.get $a) (local.get $b))
  (then (local.get $a))
  (else (local.get $b)))

In flat form, the condition comes first on the stack and if, else and end are separate instructions:

local.get $a
local.get $b
i32.gt_s
if (result i32)
  local.get $a
else
  local.get $b
end

Both assemble identically. The control structure itself is covered in writing loops and branches in WAT.

How the assembler unfolds a folded expression The assembler walks a folded expression depth first, left to right, emitting each operand's instructions before the instruction that consumes them, which reproduces the flat stack order exactly. (f64.add A B) folded input emit A operand 1 first emit B operand 2 next emit f64.add consumer last flat sequence identical bytes

Step 5 — know when flat reads better

Flat form is better in three situations. When matching code to a stack trace or a byte offset, because each line corresponds to one instruction at one position. When values deliberately stay on the stack across several operations, which folding cannot represent. And when reading compiler output, which is naturally flat and often does not fold cleanly — compilers interleave independent computations to keep the stack shallow, and folding them produces deep, awkward nesting. For writing by hand, fold arithmetic and conditions; for debugging and for reading compiled code, stay flat.

Folding and the stack discipline

Folded form hides the operand stack, which is its strength and occasionally its trap. When every instruction in a function is folded, each expression consumes exactly the values it produces internally, and the stack is empty between top-level expressions. That matches how most people think about code, and it is why folded WAT reads naturally. But WebAssembly allows — and compilers use — stack shapes that folding cannot express: a value pushed early and consumed much later, several values left for a multi-value block, a drop of a result produced by a call made for its side effect.

When hand-written folded code needs one of those patterns, there are two clean options. Store the value in a local with local.set (or local.tee when it is also needed immediately), and fold the later use around local.get — the engine turns locals into registers, so this costs nothing at runtime. Or switch that part of the function to flat form, where the stack is explicit and any shape the validator accepts can be written. Trying to force a stack-carried value into nested parentheses produces code that either does not assemble or, worse, assembles with operands in a different order than intended. Knowing where folding stops is part of using it well.

A style guide for hand-written WAT

Consistent style matters more than either choice, because WAT is read far more often than it is written. Fold expressions that compute a single value and fit in a few lines. Keep each function’s locals and parameters named after what they hold. Put stores and calls that are executed for their effects on their own lines, flat or folded, but not buried inside larger expressions. Indent nested constructs by two spaces and close parentheses at the end of the last line rather than on lines of their own, which keeps deeply nested arithmetic compact. And comment the intent — the formula, the C equivalent — above any expression longer than a line, since neither notation conveys why a computation is done. With those habits, hand-written WAT stays readable even in modules of several hundred lines.

Expected output

Assembling the folded tri_area and a hand-written flat version and comparing the binaries shows identical bytes:

wat2wasm area_folded.wat -o a.wasm && wat2wasm area_flat.wat -o b.wasm && cmp a.wasm b.wasm && echo identical
identical

Gotchas

  • Operands in the wrong order. Folding evaluates operands left to right; (i32.sub (a) (b)) is a - b. Swapping them changes the result.
  • Trying to fold a value that is already on the stack. A folded expression must contain all its operands. Use flat form, or a local, for values produced earlier.
  • Expecting folded output from the disassembler. wasm2wat prints flat by default; use -f.
  • Unbalanced parentheses. Deeply folded code is easy to break when editing. Let the editor match brackets, and reassemble after each change.
  • Missing operands. Folding with too few operands does not always fail — the assembler may take values from the stack. Count operands.
  • Deep nesting from compiler output. Folded disassembly of optimized code can be harder to read than flat. Switch back.

Performance note

Folding has no effect on the binary: the example module was 63 bytes from either form, and execution is identical. The only cost of very deep nesting is in the text — an expression folded twenty levels deep is hard to read and easy to get wrong when editing by hand.

Lines of WAT for the same function in each form Number of source lines for the triangle-area function written folded, written flat, and as disassembled with wasm2wat -f. The binary output is identical in all cases. lines of WAT (binary size identical: 63 bytes) hand-written folded 7 lines wasm2wat -f output 9 lines flat instructions 21 lines

Frequently Asked Questions

Is folded form part of the standard? Yes. The text format specification defines both forms, and every conforming assembler accepts both.

Can every instruction be folded? Almost all. Instructions with no operands are written as (i32.const 1) or (nop); a few, like drop of a value produced earlier, need flat form.

Does folding change evaluation order? No. Operands are evaluated left to right in both forms, so side effects happen in the same order.

Can comments go inside folded expressions? Yes — ;; line comments and (; … ;) block comments work anywhere, including between operands, which helps label the parts of a long formula.

Which form do tools emit? Assemblers accept both. Disassemblers default to flat, with options for folded output.

← Back to WebAssembly Text Format (WAT) Basics