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

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.

Anatomy of a WAT function An annotated function definition showing the function name and export, parameters as the first locals, the result type, an extra declared local, and the instructions that compute and leave one result on the stack. (func $hyp (export "hyp") name for WAT, export name for JS (param $x f64) (param $y f64) locals 0 and 1 (result f64) one f64 must remain at the end (local $sq f64) local 2, starts at 0.0 (local.set $sq (f64.mul (local.get $x) (local.get $x))) x² into $sq (f64.sqrt (f64.add (local.get $sq) …))) result left on the stack

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.

Locals and the stack during a call The caller pushes dx onto its operand stack, call $square pops it into the callee's parameter local, the callee computes and leaves one i32, and the call pushes that result back onto the caller's stack, where the next call and the add consume it. caller: push dx operand stack [dx] call $square pops into $v callee: v * v leaves [dx²] caller: push dy, call [dx², dy²] i32.add [dx² + dy²]

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_s on values meant as unsigned gives wrong results for large numbers.
  • Locals referenced before declaration. All local declarations 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.

Encoded size of the same function, three ways Bytes of code section for the hypotenuse function written with folded expressions, with flat instructions, and with an extra unnecessary local. Names do not affect the code section at all. bytes in the code section folded expressions 23 bytes flat instructions 23 bytes extra unused local 25 bytes

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.

← Back to WebAssembly Text Format (WAT) Basics