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:
| Namespace | Contains | Duplicate diagnostic |
|---|---|---|
| types | structs, arrays, and function types, all together | duplicate type name 'P' |
| functions | imported and defined functions, together | duplicate function name 'f' |
| globals | imported and defined globals | duplicate global name 'G' |
| memories | imported and defined memories | duplicate memory name 'M' |
| tables | imported and defined tables | duplicate table name 'T' |
| data | data segments | duplicate data name 'D' |
| tags | exception tags (not currently enabled) | — |
| locals | parameters and locals, per function | — |
| labels | block/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
| Kind | Required case |
|---|---|
| functions, parameters, locals | snake_case |
| globals, structs, arrays, function types | PascalCase |
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 kind | Form |
|---|---|
| function | import "m"."f" as func name(i32, i64) -> i32; |
| global | import "m"."f" as global Name: i32; or : mut i32 |
| memory | import "m"."f" as memory Name(min max); |
| table | import "m"."f" as table Name(n ?FuncType); |
An imported function's parameter list carries types only — double(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'.
| Property | Rule |
|---|---|
| Exportable kinds | func, global, memory, table |
| Not exportable | types (structs, arrays, function types), data segments, tags |
| One item, many names | allowed |
| Two items, one name | rejected: duplicate WebAssembly export name 'x' |
| Re-exporting an import | allowed |
| Mutable globals | exportable |
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 initializer | Rejected |
|---|---|
| a contextually typed numeric literal | a function call |
null, in a nullable-reference context | new Struct { ... } / array allocation |
| a read of an imported immutable global | a 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.get | a 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";
| Rule | Detail |
|---|---|
| Units | 64 KiB WebAssembly pages |
| Form | memory Name(min) — unbounded growth; memory Name(min max) — capped |
| Constraints | max >= min, else memory maximum must not be smaller than its initial size |
| Upper bound | memory32 limits must not exceed 65536 pages (a lowering error) |
| Addressing | i32 only; memory64 is out of the target feature set |
| Count | any number of memories, including zero |
| Default memory | none — 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:
| Attempt | Result |
|---|---|
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 differs | function 'f' does not exactly match the signature of function type 'C' |
| more initializer entries than the size | table initializer has more entries than the declared initial size |
calling Callbacks[0]() directly | a 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:
| Form | Lowers to | Type |
|---|---|---|
Callbacks[i] | table.get | ?Callback |
Callbacks[i] = &h | table.set | — |
#Callbacks | table.size | i32 |
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 getactive data segment 'Header' requires exactly one memory in the module, found 2(orfound 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.
Related
- Linear memory and function tables — self-contained worked examples
- Raw WebAssembly operations — the
wasmescape hatch and its immediates - Types — reference types, nullability, and narrowing
- Cheatsheet
- Opcode catalog — what
wastc-core-gc-0actually exposes - Spec: Declarations and Tables, memories, globals, and tags