Skip to main content

Modules, imports, and exports

One WAST compilation unit compiles to exactly one WebAssembly module. There is no linking step, no include directive, and no module system — a source file is the whole unit of compilation, and the only way to reach anything outside it is an import. That constraint is inherited from WebAssembly itself, not a temporary gap: WebAssembly's own unit of code is the module, and cross-module wiring is done by the host at instantiation time, not by the compiler.

Everything below is a declaration. A module is a flat, unordered bag of them.

Declaration order never matters

Name resolution is order-independent for every declaration kind. Exports can precede the things they export, a start can precede its target, and functions can call each other in any direction without forward declarations.

export func check as "check";

start warm_up;

func check() -> i32 {
  if (Ready == 1) {
    return helper(Ready);
  }
  return 1;
}

func warm_up() {
  Ready = 1;
}

global Ready: mut i32 = 0;

func helper(flag: i32) -> i32 {
  return flag - 1;
}

The resolver collects every top-level declaration before checking any function body, so "used before declared" is not a category of error in WAST. The only ordering that survives into the output is WebAssembly's own section ordering, which the compiler handles.

Namespaces

A name must be unique within its namespace, and namespaces are mostly — but not entirely — per declaration kind:

NamespaceContainsDuplicate diagnostic
typesstructs, arrays, and function types, all togetherduplicate type name 'P'
functionsimported and defined functions, togetherduplicate function name 'f'
globalsimported and defined globalsduplicate global name 'G'
memoriesimported and defined memoriesduplicate memory name 'M'
tablesimported and defined tablesduplicate table name 'T'
datadata segmentsduplicate data name 'D'
tagsexception tags (not currently enabled)
localsparameters and locals, per function
labelsblock/loop/for labels, lexical

The two merged namespaces are the ones that surprise people. struct P { ... } and type P = func(); collide even though they are different declaration keywords, because both emit a WebAssembly type. An imported function and a defined function collide because WebAssembly gives both a slot in the same function index space — the fact that one has a body and the other doesn't is invisible at every call site.

Namespaces that are not merged genuinely don't interact. A struct named Config and a global named Config coexist, as do a global D and a data segment D. There is no "one name, one meaning" rule; the meaning comes from the syntactic position.

Naming case is part of resolution

KindRequired case
functions, parameters, localssnake_case
globals, structs, arrays, function typesPascalCase

This is enforced, not linted. func Check() fails with function name 'Check' must use snake_case, and global counter: i32 = 0; fails with global name 'counter' must use PascalCase. Both are resolution errors, so they stop the compile.

The reason is that the resolver uses leading-character case to pick a namespace. A bare snake_case identifier in value position resolves only as a local or parameter; a bare PascalCase identifier in value position resolves only as a global. That makes the two sets disjoint by construction:

global Count: mut i32 = 0;

func bump(count: i32) -> i32 {
  Count = Count + count;
  return Count;
}

func check() -> i32 {
  bump(2);
  bump(3);
  if (Count == 5) {
    return 0;
  }
  return 1;
}

export func check as "check";

Count and count are unrelated names in the same body, and no shadowing rule is needed to say which wins — a local can never shadow a global in WAST, because a local can never be spelled like one. The cost is that the convention is not negotiable: you cannot name a helper function parseHeader, and you cannot name a global retry_count.

Imports

type Handler = func(i32) -> i32;

import "env"."double" as func double(i32) -> i32;
import "env"."tick" as global Tick: i32;
import "env"."heap" as memory Heap(1 16);
import "env"."handlers" as table Handlers(4 ?Handler);

func apply(value: i32) -> i32 {
  return double(value) + Tick;
}

export func apply as "apply";
export memory Heap as "heap";
export table Handlers as "handlers";

The two strings are the WebAssembly module name and field name. They are arbitrary strings, not identifiers, and have nothing to do with the source name that follows as.

Import kindForm
functionimport "m"."f" as func name(i32, i64) -> i32;
globalimport "m"."f" as global Name: i32; or : mut i32
memoryimport "m"."f" as memory Name(min max);
tableimport "m"."f" as table Name(n ?FuncType);

An imported function's parameter list carries types onlydouble(i32), never double(value: i32), which is a syntax error (expected a type, found identifier 'value'). There is no body to bind parameter names into, so a name there would be dead syntax. Defined functions give both, because each parameter name becomes a local.

Once imported, the name behaves exactly like a defined one. double(value) above is an ordinary direct call; nothing at the call site distinguishes it from a local function, and an imported function is a legal start target. Imported tables carry the same type restriction as declared ones: ?NamedFuncType, never ?func.

The compiler cannot check that the host actually provides these. A missing or mistyped import is an instantiation failure at runtime, which is why a module with imports compiles fine but can't be run standalone.

Exports

type Callback = func() -> i32;

memory Scratch(1);
global Counter: mut i32 = 0;

func increment() -> i32 {
  Counter = Counter + 1;
  return Counter;
}

table Callbacks(1 ?Callback) = { &increment };

func check() -> i32 {
  if (increment() != 1) { return 1; }
  if (Counter != 1) { return 2; }
  return 0;
}

export func increment as "increment";
export func increment as "inc";
export global Counter as "counter";
export memory Scratch as "memory";
export table Callbacks as "callbacks";
export func check as "check";

Exports are kind-qualified. export func and export global look up in different namespaces, so the declaration is unambiguous without the compiler inferring anything. Getting the kind wrong is a resolution error against the wrong namespace, e.g. export refers to unknown Func 'Node'.

PropertyRule
Exportable kindsfunc, global, memory, table
Not exportabletypes (structs, arrays, function types), data segments, tags
One item, many namesallowed
Two items, one namerejected: duplicate WebAssembly export name 'x'
Re-exporting an importallowed
Mutable globalsexportable

Source names and ABI names are fully independent, and there is no implicit export: a function is private unless an export declaration names it. Types are not exportable because WebAssembly has no type export — a GC struct crossing a module boundary is matched structurally by the host, not by name.

Globals

struct Node { value: i32 }

global Limit: i32 = 1024;
global Ratio: f64 = 0.5;
global Origin: ?Node = null;
global Boxed: ?i31 = wasm.ref.i31(7);
global Used: mut i32 = 0;

func reserve(amount: i32) -> i32 {
  if (Used + amount > Limit) {
    return 0 - 1;
  }
  Used = Used + amount;
  return Used;
}

func check() -> i32 {
  if (reserve(1000) != 1000) { return 1; }
  if (reserve(100) != 0 - 1) { return 2; }
  if (Origin is &Node) { return 3; }
  if (Ratio != 0.5) { return 4; }
  if (Boxed is null) { return 5; }
  return 0;
}

export global Limit as "limit";
export global Used as "used";
export func check as "check";

A global's initializer must be a WebAssembly constant expression. Nothing runs between "the module is decoded" and "the global has its value" except the constant-expression evaluator, so the permitted forms are narrow:

Allowed in a global initializerRejected
a contextually typed numeric literala function call
null, in a nullable-reference contextnew Struct { ... } / array allocation
a read of an imported immutable globala read of any defined global
a const-eligible wasm op: i32.const, i64.const, f32.const, f64.const, ref.null, ref.func, ref.i31, global.geta read of any mutable global
a local read

The third row is the trap. Reading Limit — an immutable global declared right above — from another global's initializer fails with a global initializer may only read an imported immutable global. Only imported immutable globals qualify. The reason is that WebAssembly's constant-expression global.get is restricted to the imported global index range: imports are already initialized when the global section is evaluated, and defined globals may not be. WAST reports the restriction rather than working around it by constant-folding, so the emitted WAT stays a direct translation of the source.

Other diagnostics you will hit here: a global initializer cannot call a function and this expression is not a valid constant expression for a global initializer.

Mutable globals need mut in the type (global Used: mut i32 = 0;), and are perfectly ordinary to assign from any function body. The restrictions apply to the initializer only.

Memories

memory Scratch(1 4);

func check() -> i32 {
  wasm.i32.store<Scratch, offset=0, align=4>(0, 42);
  if (wasm.i32.load<Scratch, offset=0, align=4>(0) == 42) {
    return 0;
  }
  return 1;
}

export memory Scratch as "memory";
export func check as "check";
RuleDetail
Units64 KiB WebAssembly pages
Formmemory Name(min) — unbounded growth; memory Name(min max) — capped
Constraintsmax >= min, else memory maximum must not be smaller than its initial size
Upper boundmemory32 limits must not exceed 65536 pages (a lowering error)
Addressingi32 only; memory64 is out of the target feature set
Countany number of memories, including zero
Default memorynone — every operation names its target

Nothing in the surface language reads or writes memory. All access goes through the wasm escape hatch, where the memory name is the first immediate and offset/align are the rest. align is a byte count and must be a power of two (so align=4 for an i32, align=1 for a byte load) — it is passed straight through to the WAT, and it is a hint, so an under-aligned value is legal, just slower.

Because there is no default memory, multi-memory modules need no special syntax — every site already says which memory it means:

memory Scratch(1);
memory Cache(1 2);

func stash(address: i32, value: i32) {
  wasm.i32.store<Scratch, offset=0, align=4>(address, value);
  wasm.i32.store<Cache, offset=0, align=4>(address, value);
}

func check() -> i32 {
  stash(0, 7);
  if (wasm.i32.load<Scratch, offset=0, align=4>(0) != 7) { return 1; }
  if (wasm.i32.load<Cache, offset=0, align=4>(0) != 7) { return 2; }
  let scratch_pages: i32 = wasm.memory.size<Scratch>();
  if (scratch_pages != 1) { return 3; }
  return 0;
}

export memory Scratch as "scratch";
export memory Cache as "cache";
export func check as "check";

The let scratch_pages: i32 = ... line is not stylistic. Writing wasm.memory.size<Scratch>() != 1 directly fails with cannot determine the operand type for this operator; add a typed binding — comparison operators infer their operand type from context, and a raw wasm operation does not supply one. Binding to an annotated local is the workaround.

Tables and indirect calls

A table is a sequence of references, and in WAST its type is always a nullable reference to a named function type:

type Callback = func(i32) -> i32;

func twice(x: i32) -> i32 { return x * 2; }
func negate(x: i32) -> i32 { return 0 - x; }

table Callbacks(4 ?Callback) = { &twice, &negate };

func dispatch(slot: i32, value: i32) -> i32 {
  let handler: ?Callback = Callbacks[slot];
  if (handler is &Callback) {
    return handler(value);
  }
  return 0;
}

func check() -> i32 {
  if (#Callbacks != 4) { return 1; }
  if (dispatch(0, 21) != 42) { return 2; }
  if (dispatch(1, 5) != 0 - 5) { return 3; }
  if (dispatch(2, 5) != 0) { return 4; }
  Callbacks[2] = &twice;
  if (dispatch(2, 5) != 10) { return 5; }
  return 0;
}

export table Callbacks as "callbacks";
export func check as "check";

Sharp edges, all of which produce errors rather than surprises:

AttemptResult
table T(1 ?func)rejected: a table's declared type must be a nullable reference to a named function type ('?FunctionType')
table T(1 &Callback)same error — the type must be nullable
table T(1 ?C) = { f }syntax error; an entry is null or &name, never a bare name
&f where f's signature differsfunction 'f' does not exactly match the signature of function type 'C'
more initializer entries than the sizetable initializer has more entries than the declared initial size
calling Callbacks[0]() directlya call postfix is only valid on a function name or a non-null typed function reference, found ?C

Function-reference assignability is invariant: &f matches ?C only if f's parameters and results are identical to C's. No parameter contravariance, no result covariance. This mirrors WebAssembly's call_indirect/call_ref type identity check and is why two structurally identical function types with different names are still interchangeable, while a compatible-but-not-identical signature is not.

Table syntax reuses array syntax:

FormLowers toType
Callbacks[i]table.get?Callback
Callbacks[i] = &htable.set
#Callbackstable.sizei32

Out-of-bounds indexing traps, per WebAssembly's native behaviour. Note that slot 2 above returns null, not a trap — it is in bounds, just empty, which is why dispatch(2, 5) takes the fallback path instead of aborting.

The is &Callback narrowing is mandatory. A ?Callback cannot be called, and the compiler will not insert an implicit null check. Once narrowed, the call lowers to call_ref against the named type — no call_indirect, no table index laundering.

A table is not required for indirect dispatch. &function_name produces a typed function reference anywhere the context supplies a matching named function type, so function references pass as ordinary parameters:

type BinaryOp = func(i32, i32) -> i32;

func add(a: i32, b: i32) -> i32 { return a + b; }
func mul(a: i32, b: i32) -> i32 { return a * b; }

table Ops(2 ?BinaryOp) = { &add, &mul };

func fold(op: &BinaryOp, seed: i32, count: i32) -> i32 {
  var total: i32 = seed;
  for i in 1..count {
    total = op(total, i);
  }
  return total;
}

func fold_slot(slot: i32, seed: i32, count: i32) -> i32 {
  let op: ?BinaryOp = Ops[slot];
  if (op is &BinaryOp) {
    return fold(op, seed, count);
  }
  return 0 - 1;
}

func check() -> i32 {
  if (fold_slot(0, 0, 5) != 10) { return 1; }
  if (fold_slot(1, 1, 5) != 24) { return 2; }
  if (fold_slot(0, 0, 0) != 0) { return 3; }
  return 0;
}

export func check as "check";

fold takes a non-null &BinaryOp, so it needs no narrowing of its own — the null check happens once, at the boundary where the value leaves the table. The table exists here only to give the host an index-addressable entry point; the dispatch itself is a plain call_ref.

Tables also accept an optional maximum, table T(1 4 ?C), and wasm.table.grow is in the catalog.

Data segments

memory Heap(1);

data Header at 0 = "WAST";
data Greeting = "hello";

func byte_at(address: i32) -> i32 {
  return wasm.i32.load8_u<Heap, offset=0, align=1>(address);
}

func check() -> i32 {
  if (byte_at(0) != 87) { return 1; }
  if (byte_at(3) != 84) { return 2; }
  return 0;
}

export memory Heap as "memory";
export func check as "check";

The string literal is decoded to UTF-8 bytes. There are two forms:

  • data Name at offset = "..."; is active: the bytes are copied into memory at instantiation. It requires the module to have exactly one memory, otherwise you get active data segment 'Header' requires exactly one memory in the module, found 2 (or found 0). The restriction comes from the syntax having nowhere to name a memory. In a multi-memory module, write the bytes with raw store operations instead.
  • data Name = "..."; is passive: the bytes go in the module but are never copied anywhere automatically.

Be aware that a passive segment is currently a dead end. Passive data is consumed by memory.init and released by data.drop, and neither opcode is in the wastc-core-gc-0 catalog — both report 'memory.init' is not available in the selected target's instruction catalog. Until the catalog grows those, data Name = "..."; emits a well-formed but unusable segment.

Data names live in their own namespace, so a data segment and a global may share a name.

Start functions

global Ready: mut i32 = 0;

func initialize() {
  Ready = 1;
}

start initialize;

func check() -> i32 {
  if (Ready == 1) {
    return 0;
  }
  return 1;
}

export func check as "check";

At most one start per module. The target must take no parameters and return (); anything else is start function must have no parameters and result '()'. It runs during instantiation, after globals are initialized and active data/element segments are copied, and before the host can call any export — which is what makes it the right place for the initialization work that a global initializer's constant-expression restriction forbids. An imported function is a valid target.

Tags

tag Name(types...); parses, but the current feature set rejects it: tag 'Oops' requires exception handling, which is not enabled. Exception handling is outside the target proposal set (core MVP + reference types + sign extension + GC), so throw has nothing to throw.

A complete module

Everything above in one unit — a memory with active data, a start function that reads it, a table of function references, an indirect call, and four exports under names of their own:

type Reducer = func(i32, i32) -> i32;

memory Heap(1 4);
data Banner at 0 = "WAST";

global Seed: mut i32 = 0;

func sum(a: i32, b: i32) -> i32 {
  return a + b;
}

func larger(a: i32, b: i32) -> i32 {
  if (a > b) {
    return a;
  }
  return b;
}

table Reducers(4 ?Reducer) = { &sum, &larger };

func reduce(slot: i32, count: i32) -> i32 {
  let op: ?Reducer = Reducers[slot];
  if (op is &Reducer) {
    var acc: i32 = Seed;
    for i in 1..count {
      acc = op(acc, i);
    }
    return acc;
  }
  return 0 - 1;
}

func initialize() {
  Seed = wasm.i32.load8_u<Heap, offset=0, align=1>(0);
}

start initialize;

func check() -> i32 {
  if (Seed != 87) { return 1; }
  if (reduce(0, 5) != 97) { return 2; }
  if (reduce(1, 5) != 87) { return 3; }
  if (reduce(3, 5) != 0 - 1) { return 4; }
  return 0;
}

export memory Heap as "memory";
export table Reducers as "reducers";
export global Seed as "seed";
export func check as "check";
export func check as "_start_check";

Seed cannot be initialized from Banner directly — reading memory is not a constant expression — so initialize does it at instantiation, which is why check observes 87 ('W') rather than 0. Slot 3 of the table is null because the initializer listed only two of four entries. check is exported twice, which is legal; two different functions under one name would not be.