Skip to main content

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.

KindCaseNotes
function, parameter, localsnake_caserequired, enforced
globalPascalCaserequired, enforced
struct, array, function typePascalCaseshared type namespace
labels, data segmentsown namespaceseparate 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);
};
FormTargetsCarries
br label(v)explicit label, requiredblock: its results. loop: its parameters
continue label?(v)nearest loop/for, or a named looploop parameter types
break label?(v)nearest loop/for, or a named looploop 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 ?Tbr_on_cast; is nullbr_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
OperatorOperandsResultLowering
+ - *matching i32/i64sameadd/sub/mul, wraps
/ %matching i32/i64samesigned div_s/rem_s; traps on ÷0 and signed overflow
+ - * /matching f32/f64sameIEEE 754
- (unary)any numericsame0 - v, or float neg
~ & | ^matching integerssamebitwise
<< >> >>>matching integersleftshl / shr_s / shr_u
< <= > >=matching integersi32signed only
< <= > >=matching floatsi32IEEE; false for NaN
== !=matching numericsi32eq/ne; float != true for NaN
== !=references below ?eqi32ref.eq
!i32i32eqz
#non-null array refi32array.len
&& ||i32i32short-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, if conditions, 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 return operand 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:

  1. A contextually typed numeric literal.
  2. null in a nullable-reference context.
  3. A read of an imported immutable global.
  4. A const-eligible wasm op: i32.const i64.const f32.const f64.const ref.null ref.func ref.i31 global.get.

Calls, new, local reads, and mutable-global reads are all rejected.

Where things live