Skip to main content

Limitations and rough edges

No sales pitch. This is the state of the thing, plainly, so you know what you're relying on before you write real code against it.

Status

Version 0.1.0. No tags, no changelog, no compatibility guarantee. Syntax and semantics can change without notice between commits. If you write something today, check it still compiles after you pull.

Testing is thin

There are no unit tests in the Rust crates. Correctness rests on two things:

  • tests/run.py, a black-box suite that runs a handful of fixtures through wasmtime and checks the WAT output's structure. It only proves what it covers.
  • Ad hoc scripted LSP JSON-RPC sessions, written per debugging session and not committed anywhere.

A module that passes wastc check/wasm-tools validate is not proven correct. Validation only means the WAT is well-formed WebAssembly — it says nothing about whether it does what the source says. Three real bugs have shipped and validated cleanly before being caught by actually running the output:

  • An is-narrowing test whose success path fell through into the else branch.
  • struct.new reordering field-initializer side effects instead of preserving source order.
  • Multi-parameter block/loop binding parameters in reversed stack order.

All three are fixed. The pattern isn't — see known compiler bugs below for two more of exactly this shape, found the same way — by running documentation examples rather than reading the spec — and fixed only after they had been shipping silently.

Anything touching control flow, side-effect order, or stack layout is where bugs like this hide, and the test suite's coverage of that space is whatever fixtures happen to exist, not exhaustive. If you're relying on evaluation order or exact stack shape, compile it, run it with wasmtime --invoke, and check the actual result. Use wasm-tools validate, not wasm-tools parseparse only converts text to binary and will happily accept a module the validator rejects.

Recently fixed compiler bugs

Both were found by writing and running the documentation examples, not by reading the spec. Both are now fixed and covered by conformance fixtures, but they are worth knowing about: they show the failure mode this compiler is prone to, and any .wat you generated before the fix still has them.

1. A diverging if/else as the last statement emitted invalid WebAssembly

func broken(x: i32) -> i32 {
  if (x == 0) { return 1; } else { return 2; }
}

wastc check reported no errors and the emitted module failed validation with type mismatch: expected i32 but nothing on stack. WebAssembly validates each arm independently against the block type, so the point after the if stays reachable even when both arms return; the function then fell off its end owing an i32.

Fixed by emitting an explicit unreachable after a control statement whose arms all diverge. The same gap applied to a loop with no reachable break, which is fixed too. Audit C7.

Note that wasm-tools parse accepted the broken output — it only converts text to binary. Only wasm-tools validate caught it.

2. Structurally identical sibling structs shared one runtime type

struct Node { tag: i32, }
struct Leaf : Node { value: i32, }
struct Twig : Node { count: i32, }

Leaf and Twig flatten to identical field lists under the same parent. Each was emitted as its own singleton recursive group, and WebAssembly canonicalizes groups structurally, so they became one runtime type — value is &Leaf succeeded on a Twig. The module validated, ran, and returned the wrong answer.

Fixed by emitting declarations that would canonicalize together in a single recursive group, making their identity positional. This required amending specification section 6, which had mandated the emission strategy that caused the bug while also requiring nominality. Audit C8.

This was the most dangerous bug found, because nothing reported it.

What the language does not have

Scope is core WebAssembly + reference types + sign extension + the GC proposal. That's it. Explicitly not implemented, not partially implemented, not planned in the near term:

  • Exception handling
  • SIMD
  • Threads / shared memory
  • memory64
  • Runtime strings (stringref)
  • Stack switching
  • Any GC-draft-2022 or custom-descriptors extensions

Tail calls are the one proposal with partial support, and it's narrower than it sounds: --tail-calls is a compiler output flag that may turn your own return into return_call. There is no source-level return_call syntax and no way to ask for one directly — you write an ordinary return, and whether it tail-calls is the compiler's choice, not yours.

The wasm.* escape hatch is not "any instruction"

It only covers what's hand-coded in the compiler's opcode catalog. Notably absent:

  • Branch-carrying instructions — br, br_if, br_table, block, loop, if, return, br_on_null, br_on_cast. The language's own structured control flow is the only way to branch; there is no raw escape hatch for it.
  • array.new_data, array.new_elem, memory.init, data.drop.
  • A raw call_ref or any other tail-call opcode.

If an opcode isn't in the catalog, using it is a feature error, not a silent fallback to raw WAT. There is no way to hand-write WAT and splice it in.

Things that fail silently

No diagnostic, no warning. These are the ones that will actually cost you an afternoon.

  • A narrow that doesn't apply. if (x is &T) narrows x only when x is a bare immutable local (let or a parameter). On a var, a field read, a call result, or any compound expression, the test still runs and the type simply does not change. You get a later, confusing type error at the use site instead of at the test.
  • A statically impossible is test folds to a constant-false condition. The true arm becomes dead code with no indication.
  • Ignored opcode immediates. Numeric ops never inspect their immediates, so wasm.i32.add<Junk>(a, b) type-checks and silently discards it. It is unchecked, not rejected. (select is the one exception that errors.)
  • A stale output file. A failed wastc build writes nothing, leaving a previous run's .wat in place. Check the exit code, not the file's existence.

Two entries were removed from this list once fixed, both worth knowing if you are on an older build. A failed initializer used to skip declaring its binding, so the next use reported unknown local 'x' at the resolution phase and suppressed the initializer's real error (audit P16). And operator pivot inference omitted memory.size, memory.grow, array.get_s/_u, struct.get_s/_u, and call_indirect, so wasm.memory.size<M>() != 1 was rejected outright while wasm.table.size<T>() != 4 compiled (audit P15).

Sharp edges in the escape hatch

  • wasm.i32.const<-1> does not parse — the immediate grammar has no -. Write the unsigned bit pattern (4294967295).
  • ref.test/ref.cast only downcast. The immediate must be a subtype of the operand's type.
  • ref.i31 produces a non-null &i31, but i31.get_s/i31.get_u take a nullable ?i31.
  • table.grow is (value, delta), not (delta, value).
  • call_indirect takes the table index as its last argument, after the call arguments.
  • array.copy's immediate is <Destination, Source> — destination first.
  • Bare heap keywords aren't type immediates: <&struct>, never <struct>.
  • align= is validated against the opcode's natural alignment, which is derived by substring-matching the opcode name. Unusual but consistent.

Full detail in the opcode catalog.

Structural constraints, not bugs

These are deliberate, but easy to trip over if you assume otherwise:

  • One file, one module. There is no linking and no module system. Multiple WAST files do not compose into one program — that's the embedding host's job, through imports and exports.
  • Global initializers are heavily restricted. A contextually-typed literal, null, a read of an imported immutable global, or a constant-eligible wasm op. No calls, no new, no reading a local or a mutable global.
  • ASCII only. Identifiers and keywords are ASCII in this version. No block comments — // line comments only.
  • Naming case is load-bearing, not style. snake_case resolves as a local, PascalCase resolves as a global. Get the case wrong and you get a different binding, not a lint warning.

Editor tooling (LSP)

  • Full-document sync only — no incremental didChange. Fine for the files this language is meant for; don't expect it to be pleasant on something huge.
  • The tree-sitter-wast grammar used for syntax highlighting is deliberately more permissive than the compiler: it doesn't check naming case, namespace resolution, mutability, feature gates, or which opcodes exist in the active catalog. Clean highlighting is not proof the compiler will accept the file — the grammar and the compiler are also allowed to drift out of sync with each other during active development, so a mismatch between them isn't automatically a bug in either one.
  • Hover/rename/reference ranges are inferred heuristically (from "the next thing that starts"), not computed from exact source positions, because the compiler's diagnostic spans are single points with no end offset. Usually right, occasionally off by a token at a boundary.

The playground

The in-browser playground runs the real compiler compiled to wasm32. It's checked to be byte-identical to the native compiler's output only against a curated set of examples (mise run docs:verify-wasm) — not against arbitrary input. Treat it as a convenience for trying syntax, not as the reference implementation.

Diagnostics only show one phase at a time

Errors are bucketed into syntax > resolution > feature > type > lowering, and only the earliest non-empty bucket is reported. Fixing every syntax error can therefore reveal a brand-new set of type errors — the error count going up after a fix is normal. See Diagnostics.

Where the actual rules live

This page is "what to watch out for." It is not the grammar, the type rules, or the lowering requirements — for those, read the authoritative specification. When anything here, the guide, or the compiler disagrees with it, the specification wins.

Faster lookups: syntax cheat sheet · opcode catalog · diagnostics