Skip to main content

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

TypeWATNotes
i32i32also the boolean type: 0 false, non-zero true
i64i64
f32f32
f64f64

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.

SpellingMeaning
() or omittedno result
i32one 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 + 2ok — expected type flows into both literals
!1, 1 && 2ok — these operators fix their operands at i32
-1, ~0ok — unary passes the expected type through
1 == 2error — comparison operands get no context
1 < 2error — same
a == 2 where a: i32ok — pivot comes from a
2 == a where a: i32ok — either side works
a + 1 where a: i64, expected i64ok
a + 1 where a: i32, expected i64errori32 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.
  • true is the i32 value 1, false is 0. 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.

SourceWATMeaning
&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 → toOperation
i32i64wasm.i64.extend_i32_s / _u
i64i32wasm.i32.wrap_i64
f64f32wasm.f32.demote_f64
f32f64wasm.f64.promote_f32
int → floatwasm.f64.convert_i32_s / _u, and the f32/i64 variants
float → intwasm.i32.trunc_f64_s / _u, and variants — traps out of range
bit-levelwasm.i32.reinterpret_f32, wasm.f32.reinterpret_i32, and the 64-bit pair
sign-extend in placewasm.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:

  • .s sign-extends, .u zero-extends.
  • The suffix is mandatory on a packed read and rejected on a non-packed read.
  • A write takes an i32 and 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.

LevelOperators
1 (loosest)||
2&&
3|
4^
5&
6== !=
7< <= > >=
8<< >> >>>
9+ -
10* / %
11unary ! - ~ #, 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.

OperationSignedUnsigned
shift righta >> ba >>> b
dividea / bwasm.i32.div_u(a, b)
remaindera % bwasm.i32.rem_u(a, b)
less thana < bwasm.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

BehaviourApplies 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 trapsall 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:

  1. as the operand of return, when the function's result arity matches;
  2. 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:

AttemptDiagnostic
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