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 throughwasmtimeand 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 theelsebranch. struct.newreordering field-initializer side effects instead of preserving source order.- Multi-parameter
block/loopbinding 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 parse — parse 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_refor 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)narrowsxonly whenxis a bare immutable local (letor a parameter). On avar, 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
istest 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. (selectis the one exception that errors.) - A stale output file. A failed
wastc buildwrites nothing, leaving a previous run's.watin 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.castonly downcast. The immediate must be a subtype of the operand's type.ref.i31produces a non-null&i31, buti31.get_s/i31.get_utake a nullable?i31.table.growis(value, delta), not(delta, value).call_indirecttakes 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-eligiblewasmop. No calls, nonew, 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_caseresolves as a local,PascalCaseresolves 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-wastgrammar 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