Syntax cheat sheet
Every construct in the language, on one page. Normative rules are in the specification; this is the lookup table.
Naming is load-bearing
Case is not style — the resolver uses it to pick a namespace.
| Kind | Case | Notes |
|---|---|---|
| function, parameter, local | snake_case | required, enforced |
| global | PascalCase | required, enforced |
| struct, array, function type | PascalCase | shared type namespace |
| labels, data segments | own namespace | separate from the above |
A bare snake_case value expression resolves only to a local; a bare
PascalCase one resolves only to a global. A local can therefore never
shadow a global. Wrong case is a resolution error, not a lint.
Types
i32 i64 f32 f64 value types
i8 i16 storage only — struct fields / array elements
() no result
(i32, i64) multiple results
&T non-null reference -> (ref $T)
?T nullable reference -> (ref null $T)
Built-in heap types: any eq i31 struct array none func nofunc
extern noextern. A struct name alone is never a value type — always &Name
or ?Name. Legacy spellings (anyref) do not exist; write ?any.
Subtyping: none < i31/struct/array < eq < any; nofunc < func;
noextern < extern. The three families are otherwise disjoint. Named
function types are invariant — they match only themselves.
There are no implicit numeric conversions, ever.
Declarations
struct Name { field: i32, other: mut f64 }
struct Child : Parent { own: i32 }
array Name { mut i32 }
type Callback = func(i32) -> i32;
func name(a: i32, b: &Widget) -> i32 { ... }
func unit() { ... }
func multi() -> (i32, i64) { ... }
global Name: i32 = 0;
global Counter: mut i32 = 0;
memory Heap(1); // min 1 page (64 KiB), no max
memory Heap(1 16); // min 1, max 16
table Callbacks(2 ?Callback) = { &handler, null };
data Greeting = "hi"; // passive
data Header at 0 = "WAST"; // active, requires exactly one memory
tag Failure(i32); // rejected unless exception handling is enabled
import "env"."double" as func double(i32) -> i32;
import "env"."memory" as memory Heap(1 16);
import "env"."Limit" as global Limit: i32;
import "env"."table" as table T(1 ?Callback);
export func name as "abi_name";
export global Counter as "counter";
export memory Heap as "memory";
export table Callbacks as "table";
start initialize;
Declaration order never matters. Imported functions give parameter types
only (double(i32)), defined functions give names and types. Only func,
global, memory, table are exportable — not types, not tags. One item may
be exported under several names; two items may not share an export name.
Statements
let x: i32 = 1; // immutable
var y: i32 = 1; // assignable
let (lo, hi): (i32, i32) = pair(); // destructuring, arity must match
y = 2; // var local, or mut global
obj->field = 2; // field must be mut
arr[0] = 2; // element must be mut
if (cond) { } else if (c2) { } else { }
for i in 0..10 { } // signed i32, end exclusive
return expr; // arity must match the function result
return; // only when result is ()
throw Failure(1); // requires exception handling
expr; // arity 0, or arity 1 then dropped
There is no while, no expression-form if, no compound assignment
(+=), no switch, no block comments. Comments are // only.
Control expressions
block and loop are expressions with a signature. An omitted signature means
() -> ().
let answer: i32 = block done() -> i32 {
br done(42);
};
let n: i32 = loop retry(attempt: i32 = 0) -> i32 {
if (attempt == 3) { break retry(attempt); }
continue retry(attempt + 1);
};
| Form | Targets | Carries |
|---|---|---|
br label(v) | explicit label, required | block: its results. loop: its parameters |
continue label?(v) | nearest loop/for, or a named loop | loop parameter types |
break label?(v) | nearest loop/for, or a named loop | loop result types |
continue is invalid for a block. A for accepts unlabelled break/
continue but never values. Parameter initializers are evaluated left to
right in the enclosing scope, then bound in the body.
Reaching the end of a control body is valid only when its result is ().
A non-unit block/loop needs a branch, return, or trap on every reachable
path — as does a non-unit function.
Type tests and narrowing
if (value is &Circle) { /* value : &Circle here */ }
if (value is ?Circle) { }
if (value is null) { }
is &T— null never matches.is ?T— null matches.is null— valid for any reference type.- The false arm keeps the original type. There are no complement types.
Narrowing applies only when the operand is a bare immutable (let/param)
local name. A var, a field read, a call result, or any compound expression
still evaluates the test but silently does not narrow — with no diagnostic.
Bind to a let first.
Lowering: is &T/is ?T → br_on_cast; is null → br_on_null. A
statically impossible test folds to a constant-false condition instead.
Expressions
new Point { x: 1, y: 2 } // struct: named fields, every field exactly once
new Samples { 1, 2, 3 } // array: positional, may be empty
obj->field // struct read; receiver must be non-null
obj->packed.u // packed i8/i16 read: .s or .u REQUIRED
arr[i] arr[i].s // array read (table read uses the same syntax)
#arr #Callbacks // array.len / table.size
&handler // typed function reference (needs contextual type)
f(a, b) // direct call
callback_ref(a) // indirect call via &FuncType -> call_ref
(a, b) // tuple: return operand or destructuring only
wasm.i32.add(a, b) // escape hatch
Packed (i8/i16) fields and elements require a .s/.u suffix on read
and reject it on non-packed ones. Writes take an i32 and keep the low bits.
Operator precedence
Lowest to highest binding:
||
&&
|
^
&
== !=
< <= > >=
<< >> >>>
+ -
* / %
unary ! - ~ # and &function_name
postfix () ->field [index] .s/.u
| Operator | Operands | Result | Lowering |
|---|---|---|---|
+ - * | matching i32/i64 | same | add/sub/mul, wraps |
/ % | matching i32/i64 | same | signed div_s/rem_s; traps on ÷0 and signed overflow |
+ - * / | matching f32/f64 | same | IEEE 754 |
- (unary) | any numeric | same | 0 - v, or float neg |
~ & | ^ | matching integers | same | bitwise |
<< >> >>> | matching integers | left | shl / shr_s / shr_u |
< <= > >= | matching integers | i32 | signed only |
< <= > >= | matching floats | i32 | IEEE; false for NaN |
== != | matching numerics | i32 | eq/ne; float != true for NaN |
== != | references below ?eq | i32 | ref.eq |
! | i32 | i32 | eqz |
# | non-null array ref | i32 | array.len |
&& || | i32 | i32 | short-circuit via structured if |
Unsigned comparison, unsigned division, rotation, conversion, and
reinterpretation have no operator — use wasm.<opcode>.
Arity rules
Every expression has a result arity, and it never expands implicitly.
- Arity 1 required by: call arguments, operator operands, field receivers,
array initializers,
ifconditions, struct field initializers. - Arity 0 or 1 allowed as an expression statement (arity 1 gets an explicit
drop). Arity 2+ as a statement is a type error. - Arity 2+ is allowed only as a
returnoperand or the right side of a parenthesized binding of equal arity.
Literals
42 0xFF 1_000_000 0x1_0000_0000 // i32 or i64 only, needs context
1.5 1e10 1.5e-3 // f32 or f64 only, needs context
true false // i32 1 and 0
null // needs a nullable-reference context
"text" // imports, exports, data only
Every numeric literal needs a contextual type. let x: i32 = 1 + 2; is fine;
1 + 2; is not. Negation is the unary operator, not part of the literal.
String escapes: \" \\ \n \r \t \u{...}.
Global initializers
Only these four forms are constant expressions:
- A contextually typed numeric literal.
nullin a nullable-reference context.- A read of an imported immutable global.
- A const-eligible
wasmop:i32.consti64.constf32.constf64.constref.nullref.funcref.i31global.get.
Calls, new, local reads, and mutable-global reads are all rejected.
Where things live
- Limitations — what's missing and what's undertested.
- Opcode catalog — all 199
wasm.*operations. - Diagnostics — every error message and what causes it.
- Specification — the normative document.