Types and references
WAST's type system is WebAssembly's type system with a source syntax bolted on. There is no inference across statements, no defaulting rule, and no implicit conversion of any kind. Every instruction the backend emits is determined by types you wrote down.
The cost is verbosity. The payoff is that you can read a WAST function and know exactly which opcodes come out of it. If you want a language that guesses, this is the wrong one.
Value types
| Type | WAT | Notes |
|---|---|---|
i32 | i32 | also the boolean type: 0 false, non-zero true |
i64 | i64 | |
f32 | f32 | |
f64 | f64 |
That is the complete list. i8 and i16 are storage types, not value types: they
exist only inside a struct field or array element declaration (see
Packed storage). A declared struct or array name is not a value type
either — you always reference it through & or ?.
1 func check() -> i32 {
2 let z: i8 = 1;
3 return 0;
4 }
syntax error at 2:10: expected a type, found identifier 'i8'
Both i8 and a bare struct name fail as syntax errors, not type errors — the type
grammar has no production for either, so the failure lands at parse time with no further
context:
1 struct Widget { value: i32 }
2 func check(w: &Widget) -> i32 {
3 let z: Widget = w;
4 return 0;
5 }
syntax error at 3:10: expected a type, found identifier 'Widget'
Result types
A function or control expression produces zero, one, or many values.
| Spelling | Meaning |
|---|---|
() or omitted | no result |
i32 | one result |
(i32) | one result — a single parenthesized type is that type |
(i32, i64) | two results, in that order |
Arity is part of the type and never adapts. Tuples are not heap values; they are WebAssembly's ordered result list.
func check() -> i32 {
let (a, b): (i32, i64) = (1, 2); // tuple literal
let c: i32 = (3); // `(T)` is just `T`
let (x, y): (i32, i32) = block done() -> (i32, i32) {
br done(10, 20);
};
if (a == 1 && b == 2 && c == 3 && x == 10 && y == 20) { return 0; }
return 1;
}
export func check as "check";
Literals need a context
An integer literal is valid for i32 or i64; a float literal for f32 or f64. The
syntax is identical in each pair, so there is nothing to infer from the token — and WAST
deliberately does not pick a default width. Every literal takes its type from context.
1 func check() -> i32 {
2 42;
3 1 + 2;
4 return 0;
5 }
type error at 2:3: integer literal requires a contextual 'i32' or 'i64' type
type error at 3:5: cannot determine the operand type for this operator; add a typed binding
Note the two different messages for the same underlying cause: a bare literal blames the literal, an operator over bare literals blames the operator (and points at the operator, column 5, not the expression start).
Context comes from a binding type, a parameter type, a function result type, or a typed
wasm.* operation. It propagates into arithmetic, bitwise, and shift operands — but
not into comparison operands, because a comparison always returns i32 regardless of
what it compares. For a comparison the checker instead infers a pivot type bottom-up from
whichever operand is already typed, and fails if neither is.
Expression (expected type i32) | Result |
|---|---|
1 + 2 | ok — expected type flows into both literals |
!1, 1 && 2 | ok — these operators fix their operands at i32 |
-1, ~0 | ok — unary passes the expected type through |
1 == 2 | error — comparison operands get no context |
1 < 2 | error — same |
a == 2 where a: i32 | ok — pivot comes from a |
2 == a where a: i32 | ok — either side works |
a + 1 where a: i64, expected i64 | ok |
a + 1 where a: i32, expected i64 | error — i32 is not assignable to i64 |
func takes_i64(v: i64) -> i64 { return v; }
func literal_from_result() -> i32 { return 5; }
func check() -> i32 {
// Context from the binding type flows through arithmetic into the literals.
let a: i32 = 1 + 2 * 3;
let b: i64 = 1 << 40;
// Context from a parameter type.
let c: i64 = takes_i64(7);
// Context from the function result type.
let d: i32 = literal_from_result();
// A comparison ignores the expected type for its operands and takes them
// from whichever side is already typed.
let e: i32 = a < 100;
let f: i32 = 100 < a;
// `!`, `&&`, `||` fix their operands at i32, so bare literals are fine.
let g: i32 = !0 && 1;
if (a == 7 && b == 1099511627776 && c == 7 && d == 5
&& e == 1 && f == 0 && g == 1) {
return 0;
}
return 1;
}
export func check as "check";
Literal forms and range
- Underscores are separators anywhere between digits:
1_000_000,0x1_0000_0000. trueis thei32value1,falseis0. There is no separate boolean type.- Negation is the unary
-operator, not part of the literal token.
That last point has a consequence: the accepted literal range is 0 ..= 2^width - 1, not
the signed range. 0xFFFF_FFFF and 2147483648 are both legal i32 literals and both
denote the bit pattern you'd read back as a negative number. -2147483648 parses as
-(2147483648), which is why it works at all.
func check() -> i32 {
let a: i32 = 0xFFFF_FFFF; // reads back as -1
let b: i32 = 2147483648; // reads back as i32::MIN
let c: i32 = -2147483648;
if (a == -1 && b == c) { return 0; }
return 1;
}
export func check as "check";
Exceeding 2^width - 1 is rejected:
1 func check() -> i32 {
2 let x: i32 = 4294967296;
3 return 0;
4 }
type error at 2:16: integer literal '4294967296' out of range for i32
References
Nullability is always in the spelling. There is no default and no shorthand.
| Source | WAT | Meaning |
|---|---|---|
&Widget | (ref $Widget) | non-null reference to a declared type |
?Widget | (ref null $Widget) | nullable reference |
?any | (ref null any) | nullable top of the GC family |
&i31 | (ref i31) | non-null unboxed 31-bit integer |
?extern | (ref null extern) | nullable host reference |
?none | (ref null none) | the null value's own type |
The heap types are declared structs, declared arrays, declared function types, and ten
builtins: any eq i31 struct array none func nofunc extern noextern.
Legacy WAT spellings (anyref, eqref, funcref, …) do not exist in WAST. Write
?any, ?eq, ?func. They are not aliases that were removed for style; the grammar
never had them.
1 func check() -> i32 {
2 let z: anyref = null;
3 return 0;
4 }
syntax error at 2:10: expected a type, found identifier 'anyref'
The subtype lattice
Three families, mutually disjoint except through their own bottoms:
any family: none < i31, struct, array < eq < any
none < every declared struct < struct
none < every declared array < array
declared inheritance adds the parent as a direct supertype
func family: nofunc < func
nofunc < every declared function type
(and nothing else -- declared function types are invariant)
extern family: noextern < extern
&T is assignable to ?T. A reference to a subtype is assignable to the corresponding
reference to a supertype, preserving nullability. Nothing goes the other direction: ?T
is not assignable to &T, and &any is not assignable to &eq.
struct Shape { area: f64 }
struct Circle : Shape { radius: f64 }
array Bytes { i8 }
func check() -> i32 {
let c: &Circle = new Circle { area: 1.0, radius: 2.0 };
let as_shape: &Shape = c; // declared inheritance
let as_struct: &struct = c; // every declared struct is below `struct`
let as_eq: &eq = c;
let as_any: &any = c;
let nullable: ?Circle = c; // &T is assignable to ?T
let b: &Bytes = new Bytes { 1, 2, 3 };
let as_array: &array = b;
let boxed: &i31 = wasm.ref.i31(7);
let boxed_eq: &eq = boxed;
let bottom: ?none = null;
let widened: ?any = bottom; // none sits below the whole any family
if (wasm.i31.get_s(boxed) == 7) { return 0; }
return 1;
}
export func check as "check";
The families really are disjoint — ?extern does not widen to ?any:
1 func check(x: ?extern) -> i32 {
2 let g: ?any = x;
3 return 0;
4 }
type error at 2:17: value of type ?extern is not assignable to ?any
Crossing that boundary requires wasm.any.convert_extern / wasm.extern.convert_any.
Named function types are invariant
A declared function type matches only itself. It is not below func, and two
structurally identical declarations are not interchangeable. This is a real limitation,
not a subtlety.
Its practical consequence is that &func and ?func are close to uninhabited. Nothing
produces a value of those types: &f and wasm.ref.func<f> both demand a contextual
named function type, and no declared function type widens to func. A ?func can hold
null and essentially nothing else. Table element types are likewise restricted to
?FunctionType, never ?func.
1 type Unary = func(i32) -> i32;
2 type Same = func(i32) -> i32;
3 func id(x: i32) -> i32 { return x; }
4
5 func check() -> i32 {
6 let f: &Unary = &id;
7 let g: &func = f; // and `let h: &Same = f;` fails the same way
8 return 0;
9 }
type error at 7:18: value of type &Unary is not assignable to &func
&function_name produces the reference, and the referenced function's signature must
match the contextual named type exactly — no argument or result subtyping.
1 type IntOp = func(i32) -> i32;
2 func widen(x: i32) -> i64 { return wasm.i64.extend_i32_s(x); }
3
4 func check() -> i32 {
5 let f: &IntOp = &widen;
6 return 0;
7 }
type error at 5:19: function 'widen' does not exactly match the signature of function type 'IntOp'
type IntOp = func(i32) -> i32;
func double(x: i32) -> i32 { return x * 2; }
func negate(x: i32) -> i32 { return -x; }
func apply(op: &IntOp, x: i32) -> i32 { return op(x); }
func check() -> i32 {
let a: i32 = apply(&double, 21);
let b: i32 = apply(&negate, 21);
// `==` does not work on function references; use ref.is_null.
let maybe: ?IntOp = null;
let empty: i32 = wasm.ref.is_null(maybe);
if (a == 42 && b == -21 && empty == 1) { return 0; }
return 1;
}
export func check as "check";
null
null has no type of its own. It takes one from a ?T context and lowers to
ref.null T with that heap type. It is never assignable to &T, and a use site with no
nullable context is an error rather than a guess.
1 struct Widget { value: i32 }
2 func check() -> i32 {
3 let w: &Widget = null;
4 return 0;
5 }
type error at 3:20: 'null' requires a nullable-reference contextual type
The practical consequence: a ?T cannot be dereferenced. Narrow it first with is,
which retypes a bare immutable local inside the true arm.
struct Node { value: i32 }
func value_or(node: ?Node, fallback: i32) -> i32 {
if (node is &Node) {
return node->value; // `node` is &Node inside this arm
}
return fallback;
}
func check() -> i32 {
let n: &Node = new Node { value: 7 };
let empty: ?Node = null;
if (value_or(n, -1) == 7 && value_or(empty, -1) == -1) { return 0; }
return 1;
}
export func check as "check";
Narrowing applies only to a simple immutable local. On a var, a field, or a call
result, the test still runs and the type silently does not change — no diagnostic. Bind
to a let first. See Control flow for the full narrowing rules.
No implicit conversions
Not between widths, not between signedness interpretations, not between int and float. Every conversion is a named instruction, which means the trap behaviour and the signedness are visible at the call site.
| From → to | Operation |
|---|---|
i32 → i64 | wasm.i64.extend_i32_s / _u |
i64 → i32 | wasm.i32.wrap_i64 |
f64 → f32 | wasm.f32.demote_f64 |
f32 → f64 | wasm.f64.promote_f32 |
| int → float | wasm.f64.convert_i32_s / _u, and the f32/i64 variants |
| float → int | wasm.i32.trunc_f64_s / _u, and variants — traps out of range |
| bit-level | wasm.i32.reinterpret_f32, wasm.f32.reinterpret_i32, and the 64-bit pair |
| sign-extend in place | wasm.i32.extend8_s, wasm.i32.extend16_s, wasm.i64.extend32_s, … |
There are no saturating (_sat) truncations in the catalog, so a float-to-int conversion
of an out-of-range value traps. Guard it yourself if that matters.
func check() -> i32 {
let narrow: i32 = -1;
let sext: i64 = wasm.i64.extend_i32_s(narrow); // -1
let zext: i64 = wasm.i64.extend_i32_u(narrow); // 4294967295
let back: i32 = wasm.i32.wrap_i64(zext); // -1
let wide: f64 = 1.5;
let small: f32 = wasm.f32.demote_f64(wide);
let grown: f64 = wasm.f64.promote_f32(small);
let from_int: f64 = wasm.f64.convert_i32_s(narrow); // -1.0
let to_int: i32 = wasm.i32.trunc_f64_s(2.9); // 2; traps if out of range
let bits: i32 = wasm.i32.reinterpret_f32(1.0); // 0x3F80_0000
if (sext == -1 && zext == 4294967295 && back == -1
&& grown == 1.5 && from_int == -1.0 && to_int == 2
&& bits == 0x3F80_0000) {
return 0;
}
return 1;
}
export func check as "check";
Packed storage
i8 and i16 appear only as a struct field type or an array element type. WebAssembly
stores them packed but has no packed value type, so every read must say how to widen to
i32. WAST forces you to write it:
.ssign-extends,.uzero-extends.- The suffix is mandatory on a packed read and rejected on a non-packed read.
- A write takes an
i32and keeps the low 8 or 16 bits, silently.
struct Pixel { red: mut i8, green: i8, blue: i8 }
array Samples { mut i16 }
func check() -> i32 {
let p: &Pixel = new Pixel { red: 200, green: 0, blue: 0 };
let u: i32 = p->red.u; // 200
let s: i32 = p->red.s; // -56 -- same byte, different widening
p->red = 0x1_FF; // truncated to the low 8 bits, no diagnostic
let after: i32 = p->red.u;
let sm: &Samples = new Samples { -1, 40000 };
let a: i32 = sm[0].s; // -1
let b: i32 = sm[1].u; // 40000
let c: i32 = sm[1].s; // -25536 -- same halfword as `b`
if (u == 200 && s == -56 && after == 255
&& a == -1 && b == 40000 && c == -25536) {
return 0;
}
return 1;
}
export func check as "check";
1 struct Pixel { red: i8 }
2 func check(p: &Pixel) -> i32 {
3 return p->red;
4 }
type error at 3:11: a packed field or array element cannot be read without an explicit '.s' or '.u' suffix
1 struct Point { x: i32 }
2 func check(p: &Point) -> i32 {
3 return p->x.s;
4 }
type error at 3:11: '.s'/'.u' suffixes are only valid on a packed 'i8'/'i16' field or array element
The rejection on non-packed reads is the useful half of this rule: it means a .u in the
source is proof the field is packed, so widening never becomes invisible when a field
type later changes.
The asymmetry is worth naming — reads are pedantic, writes are silent. p->red = 0x1_FF
truncates with no complaint.
Operators
Precedence
Loosest to tightest. This is C's table, level for level, so C intuitions transfer — including the bad ones.
| Level | Operators |
|---|---|
| 1 (loosest) | || |
| 2 | && |
| 3 | | |
| 4 | ^ |
| 5 | & |
| 6 | == != |
| 7 | < <= > >= |
| 8 | << >> >>> |
| 9 | + - |
| 10 | * / % |
| 11 | unary ! - ~ #, and &func_name |
| 12 (tightest) | postfix f(...), ->field, [i], .s / .u |
func check() -> i32 {
let x: i32 = 6;
let one: i32 = 1;
let zero: i32 = 0;
// `&` is looser than `==`, exactly as in C. This is `x & (one == zero)`.
let surprise: i32 = x & one == zero;
let intended: i32 = (x & one) == zero;
// Shifts bind tighter than comparison and looser than `+`.
let shifted: i32 = one << x + one; // one << 7
let compared: i32 = one << x < zero; // (one << x) < zero
if (surprise == 0 && intended == 1 && shifted == 128 && compared == 0) {
return 0;
}
return 1;
}
export func check as "check";
The literal-context rule accidentally catches the classic &-precedence bug when the
operands are literals — x & 1 == 0 fails to compile because 1 == 0 has no operand
type. Once both sides are typed locals, as above, it compiles and quietly means the wrong
thing.
Signedness: the sharp edge
Only the shifts have both spellings (>> is shr_s, >>> is shr_u). Everything else
that cares about sign is signed only. There is no unsigned operator syntax at all.
| Operation | Signed | Unsigned |
|---|---|---|
| shift right | a >> b | a >>> b |
| divide | a / b | wasm.i32.div_u(a, b) |
| remainder | a % b | wasm.i32.rem_u(a, b) |
| less than | a < b | wasm.i32.lt_u(a, b) |
<= > >= | a <= b, … | wasm.i32.le_u(a, b), gt_u, ge_u |
If you are writing unsigned code, most of the arithmetic in a function will be
wasm.* calls and the infix operators stop being useful. That is annoying and there is
no workaround. The rationale is that a single spelling cannot select two different
opcodes without an unsigned integer type to key off, and WAST does not have one.
func check() -> i32 {
let big: i32 = 0xFFFF_FFF6; // -10 signed, 4294967286 unsigned
let signed_lt: i32 = big < 10; // 1
let unsigned_lt: i32 = wasm.i32.lt_u(big, 10); // 0
let signed_div: i32 = big / 3; // -3
let unsigned_div: i32 = wasm.i32.div_u(big, 3); // 1431655762
let arith_shift: i32 = big >> 1; // -5
let logic_shift: i32 = big >>> 1; // 2147483643
if (signed_lt == 1 && unsigned_lt == 0
&& signed_div == -3 && unsigned_div == 1431655762
&& arith_shift == -5 && logic_shift == 2147483643) {
return 0;
}
return 1;
}
export func check as "check";
Traps and wrapping
| Behaviour | Applies to |
|---|---|
| wraps modulo width, no trap | + - * << on i32/i64 |
| traps on zero divisor | / % on i32/i64 |
| traps on signed overflow | / where the dividend is INT_MIN and the divisor is -1 |
| never traps | all float arithmetic, all comparisons, all bitwise ops |
func divide(a: i32, b: i32) -> i32 {
return a / b; // traps when b == 0, or when a == i32::MIN and b == -1
}
export func divide as "divide";
func check() -> i32 {
let max: i32 = 2147483647;
let wrapped: i32 = max + 1; // wraps to i32::MIN; no trap, no diagnostic
let zero: f64 = 0.0;
let nan: f64 = zero / zero; // float division by zero does not trap
let ne: i32 = nan != nan; // 1
let lt: i32 = nan < zero; // 0 -- every ordered NaN comparison is false
if (wrapped == -2147483648 && ne == 1 && lt == 0) { return 0; }
return 1;
}
export func check as "check";
Booleans
! is i32.eqz. && and || lower through structured if, not bitwise operations, so
they genuinely short-circuit. All three produce canonical 0 or 1, never the operand
value — there is no truthy-value-passthrough as in JavaScript.
global Calls: mut i32 = 0;
func bump() -> i32 {
Calls = Calls + 1;
return 1;
}
func check() -> i32 {
let a: i32 = 0 && bump(); // bump() never runs
let b: i32 = 1 || bump(); // nor here
let c: i32 = 7 && 9; // 1, not 9
let d: i32 = !5; // 0
let e: i32 = !0; // 1
if (a == 0 && b == 1 && c == 1 && d == 0 && e == 1 && Calls == 0) {
return 0;
}
return 1;
}
export func check as "check";
Reference equality
== and != also apply to references, lowering to ref.eq. This is identity, not
structural comparison.
Both operands must be at or below ?eq. ?any is above eq, so it does not
qualify, and neither does anything in the func or extern families. The diagnostic for
this is misleading — it says "numeric operands":
1 struct S { a: i32 }
2 func check(h: &S) -> i32 {
3 let a: ?any = h;
4 return a == h;
5 }
type error at 4:12: operator requires numeric operands
Use wasm.ref.test/wasm.ref.cast to get down to an eq-family type first, or
wasm.ref.is_null if you only wanted a null check.
struct Cell { value: mut i32 }
func check() -> i32 {
let a: &Cell = new Cell { value: 1 };
let b: &Cell = new Cell { value: 1 };
let alias: &Cell = a;
let empty: ?Cell = null;
let same: i32 = a == alias; // 1
let diff: i32 = a == b; // 0, despite identical fields
let isnull: i32 = empty == null; // 1
if (same == 1 && diff == 0 && isnull == 1) { return 0; }
return 1;
}
export func check as "check";
Not available as operators
min, max, abs, sqrt, ceil, floor, trunc, nearest, copysign, clz,
ctz, popcnt, rotl, rotr — all wasm.* only. % on floats is an error
('%' requires integer operands); WebAssembly has no float remainder instruction.
Multi-value
Result arity never expands or contracts implicitly. A multi-result expression may appear in exactly two places:
- as the operand of
return, when the function's result arity matches; - as the right-hand side of a parenthesized binding of equal arity.
Anywhere else — call arguments, operator operands, conditions, field initializers, expression statements — exactly one result is required.
func split(value: i64) -> (i32, i32) {
return (
wasm.i32.wrap_i64(value),
wasm.i32.wrap_i64(value >>> 32)
);
}
func sum2(low: i32, high: i32) -> i32 { return low + high; }
func check() -> i32 {
let (low, high): (i32, i32) = split(0x0000_0007_0000_0002);
let total: i32 = sum2(low, high); // must go through the bindings
if (low == 2 && high == 7 && total == 9) { return 0; }
return 1;
}
export func check as "check";
Each rejection, with split as declared above:
| Attempt | Diagnostic |
|---|---|
return sum2(split(1)); | function 'sum2' expects 2 argument(s), found 1 |
let x: i32 = split(1); | expected 1 result(s), found 2 |
split(1); | expression statement produces 2 results; expected zero or one |
The argument-splat case reports an arity error against sum2, not a multi-value error
— the checker counts one argument expression, so "found 1" is counting syntax, not
values.
There is also a diagnostic wart here. When an arity-mismatched binding fails, the local is never declared, so a later use of it produces a resolution-phase error that hides the real type error:
1 func split(v: i64) -> (i32, i32) { return (wasm.i32.wrap_i64(v), 0); }
2
3 func check() -> i32 {
4 let x: i32 = split(1);
5 return x;
6 }
resolution error at 5:10: unknown local 'x'
Fix the binding, not the "unknown local".
Putting it together
struct Header { tag: i8, flags: mut i16 }
array Payload { mut i8 }
func checksum(bytes: &Payload) -> i32 {
var total: i32 = 0;
for i in 0 .. #bytes {
total = total + bytes[i].u; // `.u` is mandatory on a packed read
}
return total;
}
func widen(sum: i32) -> i64 {
// No implicit i32 -> i64: pick the extension explicitly.
return wasm.i64.extend_i32_u(sum);
}
func check() -> i32 {
let head: &Header = new Header { tag: 200, flags: 0 };
let body: &Payload = new Payload { 1, 2, 3, 250 };
head->flags = 0x1_8000; // truncated to the low 16 bits
let flags_signed: i32 = head->flags.s; // 0x8000 sign-extends to -32768
let sum: i32 = checksum(body); // 1 + 2 + 3 + 250
let wide: i64 = widen(sum);
let as_eq: ?eq = head; // struct is below eq; &T is below ?T
let same: i32 = as_eq == head; // ref.eq: identity, not structure
if (head->tag.u == 200 && flags_signed == -32768
&& sum == 256 && wide == 256 && same == 1) {
return 0;
}
return 1;
}
export func check as "check";
See also
- Cheatsheet — the same rules, compressed further.
- Opcode catalog — every
wasm.*operation, including all the unsigned and conversion opcodes referenced above. - Low-level operations — how the escape hatch type-checks.
- Diagnostics — full message list and phase ordering.
- Spec §4, Types — normative assignability and literal rules.