Defining Functions and Locals in WAT
This page answers one task: write functions in the WebAssembly text format — with parameters, results and local variables — and understand exactly how each part maps to the binary module.
Prerequisites
- [ ] WABT installed (
wat2wasm,wasm2wat) orwasm-tools. - [ ] Node or a browser to call the result.
- [ ] The basics of a WAT module, as in writing your first WAT module by hand.
What a function is made of
A WebAssembly function has a type — its parameter and result types — and a body: a list of local variable declarations followed by
instructions. Parameters are simply the first locals; the function’s locals are numbered starting with its parameters, then the declared locals,
and instructions refer to them by index with local.get, local.set and local.tee. Values are passed around on the operand stack: instructions
pop their inputs and push their outputs, and whatever is left at the end of the body must match the result types.
The text format lets you write all of this with names instead of numbers. $a instead of local 0, $add instead of function 3. Names are a
convenience of the text format; they become indices in the binary (and, if the assembler keeps them, entries in the name section). Reading
compiled modules means reading numeric indices, so it is worth being fluent in both.
Step 1 — a function with parameters and a result
(module
(func $hyp (export "hyp") (param $x f64) (param $y f64) (result f64)
(local $sq f64)
(local.set $sq (f64.mul (local.get $x) (local.get $x)))
(f64.sqrt
(f64.add (local.get $sq)
(f64.mul (local.get $y) (local.get $y))))))
wat2wasm hyp.wat -o hyp.wasm
const { instance } = await WebAssembly.instantiate(await (await fetch("hyp.wasm")).arrayBuffer());
console.log(instance.exports.hyp(3, 4)); // 5
The parameters $x and $y are locals 0 and 1; $sq is local 2. Declared locals start at zero — 0 for integers, 0.0 for floats, null for
references — which WebAssembly guarantees, so there is no uninitialised-variable bug to worry about.
Step 2 — the same function with numeric indices
The disassembler shows what the binary actually contains:
wasm2wat hyp.wasm --no-debug-names
(func (;0;) (type 0) (param f64 f64) (result f64)
(local f64)
local.get 0
local.get 0
f64.mul
local.set 2
local.get 2
local.get 1
local.get 1
f64.mul
f64.add
f64.sqrt)
Two differences stand out. Names are gone, replaced by indices. And the nested folded form has become a flat list of instructions in stack order — the binary format has no nesting, only a sequence. Both forms are valid WAT and assemble to the same bytes; folded expressions are covered in using folded expressions in WAT.
Step 3 — use local.tee to store and keep a value
local.set pops a value and stores it. local.tee stores it and leaves it on the stack, which saves a local.get when a value is both kept
and used immediately:
(func $sum_squares (export "sum_squares") (param $n i32) (result i32)
(local $i i32) (local $acc i32) (local $sq i32)
(block $done
(loop $next
(br_if $done (i32.ge_u (local.get $i) (local.get $n)))
(local.set $acc
(i32.add (local.get $acc)
(local.tee $sq (i32.mul (local.get $i) (local.get $i))))) ;; store i² and use it
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
(local.get $acc))
Compilers emit local.tee constantly; recognising it makes compiled code much easier to read. Loops and branches like this are the subject of
writing loops and branches in WAT.
Step 4 — call other functions
Functions call each other by name or index with call. The callee’s parameters are popped from the caller’s stack in order and its results are
pushed back:
(module
(func $square (param $v i32) (result i32)
(i32.mul (local.get $v) (local.get $v)))
(func (export "dist2") (param $dx i32) (param $dy i32) (result i32)
(i32.add (call $square (local.get $dx))
(call $square (local.get $dy)))))
$square is not exported, so JavaScript cannot call it, but other functions in the module can. Unexported, uncalled functions are dead code that
toolchains remove; in hand-written WAT nothing removes them, so keep the module tidy yourself.
Step 5 — choose types deliberately
WebAssembly has four number types — i32, i64, f32, f64 — plus v128 for SIMD and reference types. Integers have no signedness of their
own; instructions decide, so i32.div_s divides as signed and i32.div_u as unsigned, and i32.lt_s and i32.lt_u compare differently. That
surprises people coming from C, where the variable’s type decides. Choose the instruction that matches the meaning, and keep i32 for pointers in
32-bit memories.
Mind the JavaScript boundary when exporting. i32 and f32/f64 arrive as ordinary numbers; i64 arrives and leaves as BigInt, which costs an
allocation per call and requires callers to pass BigInt values — the details are in
passing 64-bit integers with BigInt.
Reading compiler output with these rules in mind
Most WAT you will read was written by a compiler, not a person, and it uses the same constructs with different habits. Compiled functions
usually have many locals with no names — (local i32 i32 i32 f64) — because the compiler allocated one per temporary value before optimizing.
Parameters that are pointers are plain i32s; you identify them by how they are used, as the address operand of loads and stores. local.tee
appears constantly, as does the pattern global.get $__stack_pointer, i32.const N, i32.sub at the top of a function, which allocates a frame
on the shadow stack as described in
understanding the shadow stack in linear memory.
A useful exercise is to compile a small C or Rust function at -O2, disassemble it, and annotate it line by line with what each local holds. Two
or three such exercises are enough to read compiler output fluently: once you know that local 3 is the loop counter and local 4 the running total,
the flat instruction list turns back into the loop it came from. Keep the name section in builds you inspect, so at least function names survive
— that alone makes most disassemblies navigable.
Why hand-written WAT is still worth knowing
Few people write production modules entirely in WAT, but the format repays learning. It is how you read what a compiler produced, which is the first step in understanding a size problem or a performance surprise. It is the quickest way to try an instruction or a proposal without a toolchain. It is ideal for tiny modules — feature probes, small kernels, test fixtures — where a compiler and its runtime would add more than the code itself. And it makes the JavaScript API concrete: once you have written an import, an export and a function by hand, the import objects and glue that toolchains generate stop being mysterious.
Expected output
hyp(3, 4) → 5
sum_squares(4) → 14 (0 + 1 + 4 + 9)
dist2(3, 4) → 25
Gotchas
- Type mismatch at the end of a function. The stack must hold exactly the result types. A leftover value needs a
drop. - Mixing signed and unsigned instructions.
i32.div_son values meant as unsigned gives wrong results for large numbers. - Locals referenced before declaration. All
localdeclarations must come before any instruction in the body. - Exporting i64 functions to JavaScript without BigInt. Calls with plain numbers throw a
TypeError.
Performance note
The way locals are written in WAT has no effect on performance: engines convert locals to registers and the result is the same whether a value is kept in one local or three. What matters is the instructions themselves — a function with fewer, cheaper instructions is faster — and, at the boundary, the number and types of calls.
Frequently Asked Questions
Can a function have no result?
Yes — omit (result …). The stack must then be empty at the end.
How many locals can a function have? Engines allow tens of thousands. More locals do not slow the code down; register allocation decides.
Can locals be shared between functions? No. Locals belong to one function call. Use globals or linear memory for shared state.
Can I export the same function under two names?
Yes — add two export entries pointing at the same function. Both names call the same code, which is handy for keeping an old name working
during a rename.
What happens if JavaScript passes the wrong number of arguments?
Missing arguments become undefined and convert to 0 or NaN; extra arguments are ignored. Validate in a wrapper if callers can get it wrong.
Do names survive into the binary?
Only in the name custom section, and only if the assembler emits it. They never affect behaviour.
Related
- WebAssembly text format (WAT) basics — the topic overview.
- How the Wasm operand stack works — what the instructions do to the stack.
- How Wasm locals map to machine registers — why local count does not matter.
- Importing JavaScript functions into WAT — calling out of the module.
← Back to WebAssembly Text Format (WAT) Basics