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
- [ ] WABT (
wat2wasm,wasm2wat) orwasm-tools. - [ ] Basic WAT functions and the operand stack, as in how the Wasm operand stack works.
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.
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.
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))isa - 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.
wasm2watprints 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.
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.
Related
- Defining functions and locals in WAT — the folded examples there in context.
- Writing your first WAT module by hand — starting point for hand-written WAT.
- Understanding structured control flow in Wasm — what folded control constructs mean.
- Reading the type section — where function signatures end up.
← Back to WebAssembly Text Format (WAT) Basics