Skip to main content

Counter and globals

A mutable global, two direct calls, and one export. The interesting part is not the arithmetic — it is that the compiler decides Counter is a global purely from its spelling.

// `mut` is what makes this assignable. Without it this is an immutable
// WebAssembly global and `Counter = ...` below is a type error:
// "global 'Counter' is not mutable".
global Counter: mut i32 = 0;

func increment() -> i32 {
  // Read-modify-write, lowered as global.get / i32.add / global.set.
  // Nothing atomic about it -- but the instruction catalog excludes the
  // threads proposal, so no other agent exists to observe the gap.
  Counter = Counter + 1;
  return Counter;
}

func check() -> i32 {
  // `let` snapshots the returned value into a WebAssembly local. `first`
  // stays 1 forever; it is not a live view onto Counter.
  let first: i32 = increment();
  let second: i32 = increment();

  // Three different namespaces, disambiguated only by case:
  //   `first`/`second` are snake_case -> locals, never globals.
  //   `Counter`        is PascalCase  -> global, never a local.
  // This read re-reads the global, so it sees 2.
  //
  // `&&` short-circuits. It lowers to nested `if (result i32)` blocks, not
  // to i32.and, and normalizes each operand to 0/1 with a double i32.eqz.
  if (first == 1 && second == 2 && Counter == 2) {
    return 0;
  }
  // Not optional. A non-unit function may not fall off its closing brace;
  // deleting this line fails with "function must return a value of type i32
  // on every reachable path".
  return 1;
}

export func check as "check";

Why case is load-bearing

Most languages resolve a bare identifier by walking scopes: innermost local, then enclosing scopes, then module scope, then globals. That walk is where shadowing bugs live — you add a local named counter and a distant line that meant the global silently starts meaning your local.

WAST removes the walk. Per spec section 5, locals, parameters, and functions must be snake_case; globals, structs, arrays, and function types must be PascalCase. The resolver dispatches on the leading character's case, so the two namespaces are disjoint by construction. A local cannot shadow a global because a name that could denote a local is not a name that could denote a global. There is no precedence rule to learn, because there is no ambiguity to resolve.

What it costs you: naming is not yours to choose, and the convention is a hard resolution-phase diagnostic, not a lint you can suppress. global MAX_SIZE is rejected with global name 'MAX_SIZE' must use PascalCase — the usual screaming-snake spelling for a constant is unavailable. func DoThing is rejected the same way in the other direction.

The failure mode is worth seeing once, because it shows the dispatch happening. Writing let Temp: i32 = 1; return Temp; produces two errors:

resolution error at 1:19: local name 'Temp' must use snake_case
resolution error at 1:45: unknown global 'Temp'

The second one is the point. The resolver did not know or care that you had just declared Temp two statements earlier — it saw a PascalCase identifier in value position and looked it up in the global namespace, which is the only place that spelling can resolve. Case is consulted before any scope is.

Why globals stay boring

global Counter: mut i32 = 0; becomes exactly one WebAssembly global — (global $Counter (mut i32) (i32.const 0)). There is no storage object, no host binding, no accessor pair generated behind it. The initializer is a constant expression evaluated at compile time, not code that runs at instantiation.

This matters when you export the module: a consumer sees a plain Wasm global, and a WAST global's mutability is the Wasm global's mutability. Exporting a mut global exposes it as writable to the host, with no encapsulation layer to stop that. If you want a read-only view, do not declare it mut and initialize it in place, or hide it behind an exported function.

Expected result

check() returns 0. Calls observe 1 then 2, and Counter retains 2.