Skip to main content

Types and references

Reed'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 Reed 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
booli32transparent source alias of i32; false is 0, true is 1
i32i32also accepted anywhere bool is accepted
i64i64
f32f32
f64f64

bool deliberately adds no nominal distinction from i32; it documents intent without changing WebAssembly representation or compatibility. 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'

i8 fails as a syntax error. A bare struct name is parsed as an alias use and then rejected during resolution because structs still require & or ?:

1 struct Widget { value: i32 }
2 func check(w: &Widget) -> i32 {
3 let z: Widget = w;
4 return 0;
5 }

resolution error at 3:10: unknown type alias 'Widget'

Type aliases

type Name = Type; introduces a transparent alias for a complete value or reference type. It emits nothing and can refer forward to another alias or declared heap type.

type Count = i32;
type Enabled = bool;
type MaybeWidget = ?Widget;

struct Widget { count: Count }

func read(enabled: Enabled, widget: MaybeWidget) -> Count {
  if (enabled) {
    if (widget is &Widget) {
      return widget->count;
    }
  }
  return 0;
}

export func read as "read";

Aliases are not nominal wrappers. Count, bool, and i32 are mutually assignable and all lower to i32. Cycles are rejected. Because an alias names a complete type rather than a heap type, write type MaybeWidget = ?Widget; and then MaybeWidget, not ?MaybeWidget.

Enumerations

enum names a group of related integer constants, plus a transparent alias for the integer type holding them:

enum Color { Red, Green, Blue }

func brightness(c: Color) -> i32 {
  switch (c) {
    case Color.Red: { return 10; }
    case Color.Green: { return 20; }
    case Color.Blue: { return 30; }
    default: { return 0; }
  }
}

func check() -> i32 {
  if (brightness(Color.Green) != 20) { return 1; }
  return 0;
}

export func check as "check";

Members number from 0 upward. An explicit value resets the count, so the member after Green = 5 is 6, not 2:

enum Status {
  Pending,        // 0
  Active = 5,     // explicit
  Retiring,       // 6
  Done = Active,  // 5 again -- values need not be distinct
}

A member's value is a compile-time expression, so it may name an earlier member of the same enum (by its bare name), any param or const, or a member of another enum:

const Offset: i32 = 100;
enum Signal : i64 { Low = Offset, High = Low + 1 }

The bare form (Low) works only inside its own enum's initializers. Everywhere else a member is Enum.Member, and Color on its own is not a value at all.

It is C-style, and the alias is transparent

Color used as a type means exactly i32. There is no nominal identity, no wrapper, and no conversion to write:

enum Color { Red, Green, Blue }

func widen(c: Color) -> i32 { return c; }   // no cast needed
func narrow(v: i32) -> Color { return v; }  // also fine, including v = 99

That last line is deliberate. An enum value is an ordinary integer, so an out-of-range one is representable and a switch over members still needs its default. If you want a type that can only hold declared values, this is not that construct.

Sizing an enum

The : repr clause takes a bit width, spelled the way a packed struct field's type is: iN signed, uN unsigned, with i32 (the default) and i64 being the widths 32 and 64.

enum Color : u4 { Red, Green, Blue }    // 4 bits, unsigned
enum Delta : i5 { Down = -3, Up = 3 }   // 5 bits, signed
enum Signal : i64 { Low = 0, High = 1 } // the wide representation

Members are range-checked at the width you pick, and the message names it:

enum Color : u4 { Big = 20 }
// error: enum member 'Color.Big' is 20, out of range for 'u4' (0..15)

A narrow width is a storage width, not a new value type — the same distinction Reed already draws for i8/i16, which are storage types that read as i32. So Color : u4 still means i32 wherever a value type is expected, and the width shows up only where a width is the subject: in a field's storage. See where the width matters below.

Where a member can be used

Anywhere a const can: an ordinary expression, a global initializer, a switch case, a comptime if condition, a comptime for bound, and str!(...). They are the same mechanism -- an enum member is a compile-time constant that happens to be named through its type.

enum Color { Red, Green, Blue }

global Fallback: Color = Color.Blue;

comptime for i in Color.Red..Color.Blue {
  func [<swatch $i>]() -> i32 { return $i; }
}

Where the width matters

An enum name works as a field type in all three field positions, and the declared width decides the storage in each:

enum Color : u4 { Red, Green, Blue }

// A packed struct field occupies EXACTLY the declared width, so `flag` starts at bit 4.
packed struct Pixel : i32 { c: Color, flag: bool }

// A normal struct field or array element rounds up to the nearest storage type: i8 here.
struct Cell { c: Color }

array Palette { mut Color }

func check() -> i32 {
  let p: Pixel = new Pixel { c: Color.Blue, flag: true };
  if (p->c != 2) {
    return 1;
  }
  let cell: &Cell = new Cell { c: Color.Green };
  if (cell->c != 1) {
    return 2;
  }
  let pal: &Palette = new Palette[2]{Color.Red};
  pal[1] = Color.Blue;
  if (pal[1] != 2) {
    return 3;
  }
  return 0;
}

export func check as "check";

The rounding ladder is i8 for 1..8 bits, i16 for 9..16, and otherwise the value type — those are the only storage types WebAssembly has (see packed storage). So Cell above lowers to (struct (field $c i8)), and a u12 enum field would be i16.

Note what this means in practice: widening an enum from u4 to u12 later changes a normal struct's field from i8 to i16. That is a real change to the emitted module from an edit that looks source-compatible — the same exposure a field declared i8 directly already has.

No .s/.u suffix is needed on a narrow enum field, unlike a plain i8/i16 one. The enum's representation already says whether a read sign-extends, so requiring a suffix would ask you to restate it — and let you contradict it. An explicit suffix is still accepted and still wins.

In every other position — a parameter, a local, a return type, a global, and the type of Color.Blue itself — the enum name means its value type, whatever width you declared. That is the transparency guarantee: a width never changes what the name means as a value.

Transparency cuts the other way too, and this is worth knowing before reaching for it: the field is an ordinary integer, so new Cell { c: 7 } is accepted even though no member has that value. A width constrains storage, not the set of values. An enum here names constants and documents intent; it does not make a field's range checkable.

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 Reed 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

  • An integer may be written in decimal, hexadecimal (0x), or binary (0b). The prefix letter may be either case (0XFF, 0B1010), matching WAT's own tolerance.
  • Underscores are separators anywhere between digits: 1_000_000, 0x1_0000_0000, 0b1010_1010.
  • true is the bool/i32 value 1, and false is 0.
  • Negation is the unary - operator, not part of the literal token.

A digit outside the radix is an error at the whole literal, not a silent split: 0b12 is rejected rather than read as 0b1 followed by 2.

func check() -> i32 {
  // Three spellings of the same byte, plus the flag-set idiom binary is for.
  let dec: i32 = 170;
  let hex: i32 = 0xAA;
  let bin: i32 = 0b1010_1010;
  let flags: i32 = 0b0000_0110;
  if (dec == hex && hex == bin && flags == 6) { return 0; }
  return 1;
}
export func check as "check";

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 Reed. 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 — parameters, results, and the checked throws (...) set all participate, with no subtyping. A function that throws fewer tags does not currently coerce to a type that permits more; spell an exactly matching wrapper when that adaptation is needed.

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
i32&i31wasm.ref.i31
&i31/?i31i32wasm.i31.get_s / _u
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.

Most of the table above also has a dedicated cast-expression spelling: expr as type (and expr as type.s/expr as type.u where the target's signedness is otherwise ambiguous) lowers to the identical instruction and is the preferred surface syntax — the wasm.* form is what it compiles to, not a separate way to do it. Two rows are deliberately unreachable through as and stay escape-hatch-only:

  • Sign-extend in place (extend8_s/extend16_s/extend32_s) converts i32 to i32 or i64 to i64 — same type on both sides — and as rejects a cast whose source and target type are identical outright, before it ever looks for a matching instruction: n as i32.s (with n: i32) fails with 'i32 as i32' converts a type to itself, which has no instruction.
  • Bit-level reinterpretation (i32/f32, i64/f64) has distinct source and target types, but the (source, target) type pair alone can't tell as whether you mean "convert this number" or "keep these bits" — i32 as f32 always means convert.

See as conversions for the full grammar, the .s/.u disambiguation rules, and a compiling example.

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. Reed 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 Reed 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