Skip to main content

Reed Language Specification

1. Status and Terms

This document specifies Reed, a typed source language which compiles to a single WebAssembly module. It is intentionally close to WebAssembly while providing named declarations, expression syntax, structured control flow, and automatic GC type construction.

The words MUST, MUST NOT, SHOULD, and MAY are normative.

An implementation MUST report a source diagnostic for any program that does not satisfy this specification. It MUST NOT emit a module for a program with a semantic error.

Invalid programs are classified as syntax, expansion, resolution, feature, type, or lowering errors. Implementations MUST perform those checks in that order, and MUST report all errors found in each completed phase. An expansion error arises while evaluating compile-time param/comptime for/comptime if constructs, as described in section 13, and is reported before resolution begins — by the time resolution runs, no trace of those constructs remains in the program. A lowering error is raised when a well-typed source construct has no valid WebAssembly representation under the selected target features.

2. Target

The initial target feature set is:

  • WebAssembly core instructions and value types.
  • Fixed-width SIMD and relaxed SIMD: the v128 value type and the lane-wise instruction set, as described in section 4.1 and section 12.1.
  • Custom sections and branch hinting, through the annotation forms of section 5.1.
  • Multi-memory. A memory's address width is per-declaration: 32-bit by default, or 64-bit when declared i64 (the memory64 proposal).
  • Shared memory and atomic memory operations (the threads proposal), as described in section 10.2 and section 12.2.
  • Multi-value functions.
  • Reference types.
  • Garbage-collection types: structs, arrays, i31, casts, tests, and recursive type groups.
  • Typed low-level operations, as described in section 12.
  • Exception handling in full: declaring a tag, throwing it, and catching it, as described in section 10 and section 10.1.
  • Extended-const: i32.add/i32.sub/i32.mul and i64.add/i64.sub/ i64.mul as constant expressions in a global initializer (section 10). This does not add general arbitrary-precision compile-time arithmetic -- only these six opcodes, only in a global initializer.

Runtime strings, stack switching, and other WebAssembly proposals are outside this target. Tail calls are an optional output mode, as specified in section 14; they are not a source feature. Their syntax MAY be reserved, but implementations MUST reject their use until that feature is enabled.

A compilation unit produces exactly one WebAssembly module. Linking modules is the responsibility of the embedding environment through imports and exports.

3. Lexical Structure

Source is UTF-8. Identifiers and keywords use ASCII characters in the initial version.

Whitespace separates tokens and is otherwise insignificant. Line comments begin with // and continue through the end of the line. Block comments are not part of the initial language.

A line comment beginning with /// is a documentation comment when it appears on its own line immediately before a declaration that introduces a name, or immediately before that declaration's annotation/pub prefixes. A contiguous group of such comments is attached to that declaration as Markdown documentation after removing the leading /// and one optional following space from each line. A blank line or any non-documentation comment between the group and the declaration breaks the attachment. A /// group before a declaration that introduces no name (an export, a start, a data segment) attaches to nothing; this MUST NOT be an error, since such a comment is still a reasonable thing to write.

Documentation comments do not affect type checking or lowering, and an implementation MUST NOT let the presence, absence, or content of one change what a module compiles to. An implementation MAY expose them through editor tooling or generated reference documentation.

An implementation that generates reference documentation SHOULD include every declaration, whether or not it carries a documentation comment. Omitting the undocumented ones makes the output indistinguishable from a complete reference to a smaller interface.

An identifier begins with an ASCII letter or _ and is followed by zero or more ASCII letters, digits, or _. The following words are reserved:

array as block break continue data else elem export false for func global if import
in is let loop memory mut new null return start struct table throw true type
unreachable var wasm

The literals null, integer literals, floating-point literals, and string literals follow the token grammar below. String literals are permitted in imports, exports, and data declarations.

literal ::= "null" | "true" | "false" | integer-literal | float-literal
integer-literal ::= decimal-integer | hexadecimal-integer | binary-integer
decimal-integer ::= decimal-digit ("_"? decimal-digit)*
hexadecimal-integer ::= ("0x" | "0X") hexadecimal-digit ("_"? hexadecimal-digit)*
binary-integer ::= ("0b" | "0B") binary-digit ("_"? binary-digit)*
binary-digit ::= "0" | "1"
float-literal ::= decimal-float
decimal-float ::= decimal-integer "." decimal-integer? exponent?
| decimal-integer exponent
exponent ::= ("e" | "E") ("+" | "-")? decimal-integer
string-literal ::= '"' string-character* '"'
string-character ::= any Unicode scalar value except `"`, `\\`, or a line break
| `\\"` | `\\\\` | `\\n` | `\\r` | `\\t`
| `\\u{` hexadecimal-digit{1,6} `}`

true is the i32 value 1 and false is the i32 value 0. Integer and floating-point literals require a contextual destination type. An integer literal is valid only for i32 or i64. A non-negative integer must be at most 2^width - 1. Negation is parsed as the unary - operator. The emitted integer is the corresponding width-bit pattern. A floating literal is valid only for f32 or f64 and rounds using the corresponding IEEE 754 conversion. A literal without a contextual type is a type error. Implementations MUST apply maximal-munch tokenization to multi-character punctuation, including ->, .., >>>, >>, <<, <=, >=, ==, !=, &&, and ||.

4. Types

4.1 Value Types

The scalar value types are i32, i64, f32, and f64. bool is a predefined transparent alias of i32: it lowers identically, accepts the same values, and exists to state boolean intent in source signatures. true and false denote the i32 values 1 and 0 respectively. i8 and i16 are storage types, not value types: they appear only in struct fields and array element declarations as specified in section 6.

v128 is a value type but not a numeric one. It is inhabited only by values produced by the lane-wise operations of section 12.1: it has no literal syntax, no arithmetic, comparison, or bitwise operators, no as conversion to or from any other type, and no built-in methods. (A v128 MAY be the receiver of a method declared under section 7.1, which is an ordinary function call and not a lane-typed operation, so it raises none of the ambiguity below.) An implementation MUST report a type error for any such use, and that diagnostic SHOULD name the lane-typed operation that does apply, since WebAssembly's vector instructions are lane-typed and the source has no way to state a lane interpretation for a bare +. A v128 MAY appear anywhere a value type is required otherwise: as a parameter, result, local, global, struct field, or array element type.

A function or block may produce no result, one result, or multiple results. The type () denotes no result. Parenthesized comma-separated types denote multiple results, for example (i32, i64). A single parenthesized type is equivalent to that type.

4.2 Heap and Reference Types

Heap types include declared structs, declared arrays, declared function types, and the built-in WebAssembly heap types:

any eq i31 struct array none func nofunc extern noextern exn noexn

exn and noexn are the exception heap types (section 10.1). They form their own family, disjoint from any/eq, func, and extern, so no cast or test relates an exn to any other reference type. An exn value can only be obtained from a catch ... as name clause and can only be consumed by the rethrow form of section 10.1.

Reference types MUST be written explicitly:

&HeapType // non-null reference
?HeapType // nullable reference

For example, &Widget lowers to (ref $Widget) and ?Widget lowers to (ref null $Widget). A declared struct name by itself is never a value type and MUST NOT be used where a type is required.

null denotes the nullable bottom reference. Its concrete source type and WebAssembly heap annotation are supplied by context as defined in section 4.3.

The legacy spellings such as anyref are not part of Reed. Source using them MUST use the equivalent explicit form, such as ?any.

4.3 Assignability

Values of type T are assignable to T. A non-null reference &T is assignable to ?T. A reference to a subtype is assignable to the corresponding reference to its supertype, preserving nullability.

The built-in heap-type relation is fixed as follows. none is a subtype of i31, struct, array, eq, and any; i31, struct, and array are subtypes of eq, and eq is a subtype of any. nofunc is a subtype of func; noextern is a subtype of extern; noexn is a subtype of exn. The any/eq family, func family, extern family, and exn family are otherwise disjoint. Every declared struct is a subtype of struct, eq, and any; every declared array is a subtype of array, eq, and any; declared struct inheritance adds the declared parent as a direct supertype.

null is contextually typed. For an expected ?T, it lowers to ref.null T; it has no type outside a context that supplies ?T. It is never assignable to a non-null reference. This contextual choice is the only null representation chosen by the compiler; it MUST NOT be inferred from an unrelated use site.

No implicit numeric conversions occur. Numeric conversion, extension, truncation, reinterpretation, and reference casts MUST use an applicable operator or low-level operation.

5. Declarations

A module consists of declarations. Declaration order does not affect name resolution, except that a name MUST be unique within its declaration namespace. Structs, arrays, function types, and type aliases share the type namespace. Imported and defined functions share the function namespace; imported and defined globals, memories, tables, and tags likewise share their respective namespaces. Labels and locals have separate lexical namespaces.

Local variables, parameters, and functions MUST use snake_case. Globals MUST use PascalCase. Structs, arrays, function types, and type aliases MUST use PascalCase. This convention is part of name resolution, not style guidance: an identifier in a non-call value expression with snake_case spelling resolves only to a local, while one with PascalCase spelling resolves only to a global. A snake_case identifier in direct call position resolves in the function namespace. Type identifiers resolve only in type positions.

module ::= declaration*
declaration ::= ("pub")? declaration-body
declaration-body ::= struct-decl | array-decl | func-type-decl | type-alias-decl
| enum-decl | packed-struct-decl
| import-decl | func-decl
| table-decl | memory-decl | global-decl | tag-decl
| data-decl | elem-decl | export-decl | start-decl
| custom-annotation
| param-decl | comptime-for-decl | comptime-if-decl
| comptime-func-decl | comptime-instantiation | const-decl
| use-decl | macro-decl

struct-decl ::= "struct" identifier (":" identifier)? "{" field-decl* "}"
array-decl ::= "array" identifier "{" field-type "}"
field-decl ::= identifier ":" field-type ","?
field-type ::= type | storage-type | "mut" type | "mut" storage-type
storage-type ::= "i8" | "i16"

func-type-decl ::= "type" identifier "=" "func" "(" type-list? ")"
("->" result-type)? throws-clause? ";"
type-alias-decl ::= "type" identifier "=" type ";"
use-decl ::= "pub"? "use" module-path "." use-items ";"
module-path ::= path-segment ("." path-segment)*
path-segment ::= identifier | keyword
use-items ::= "*" | "{" use-item ("," use-item)* ","? "}"
use-item ::= identifier ("as" identifier)?
import-decl ::= "import" (string-literal | identifier) "." string-literal
"as" import-kind
import-kind ::= "func" identifier "(" parameter-type-list? ")"
("->" result-type)? throws-clause? ";"
| "global" identifier ":" global-type ";"
| "memory" identifier "(" memory-type ")" ";"
| "table" identifier "(" table-limits type ")" ";"
| "tag" identifier ("(" parameter-type-list? ")")? ";"

custom-annotation ::= "@" "custom" "(" string-literal ("," string-literal)? ")"
custom-placement? ";"
custom-placement ::= ("before" | "after") ("code" | "data" | "first" | "last")
inline-annotation ::= "@" "inline"

func-decl ::= inline-annotation? "func" identifier "(" parameter-list? ")"
("->" result-type)? throws-clause? block
throws-clause ::= "throws" "(" (identifier ("," identifier)* ","?)? ")"
parameter-list ::= parameter ("," parameter)* ","?
parameter ::= identifier ":" type | receiver-shorthand
receiver-shorthand ::= "&"? "self"
parameter-type-list ::= type ("," type)* ","?
result-type ::= type | "(" type-list? ")"
type-list ::= type ("," type)* ","?
type ::= value-type | reference-type | identifier
value-type ::= "bool" | "i32" | "i64" | "f32" | "f64" | "v128"
reference-type ::= "&" heap-type | "?" heap-type
heap-type ::= identifier | "any" | "eq" | "i31" | "struct" | "array"
| "none" | "func" | "nofunc" | "extern" | "noextern"

table-decl ::= "table" identifier "(" table-limits type ")"
("=" "{" table-entry-list? "}")? ";"
table-entry-list ::= table-entry ("," table-entry)* ","?
table-entry ::= "null" | function-reference
function-reference ::= "&" function-identifier
function-identifier ::= identifier
table-limits ::= integer-literal (integer-literal)?
memory-decl ::= "memory" identifier "(" memory-type ")" ";"
memory-type ::= ("i64")? memory-limits ("shared")?
memory-limits ::= integer-literal (integer-literal)?
data-decl ::= "data" identifier ("at" integer-literal)? "=" string-literal ";"
elem-decl ::= "elem" identifier ":" type "=" "{" table-entry-list? "}" ";"
global-decl ::= "global" identifier ":" global-type "=" expression ";"
global-type ::= ("mut")? type
tag-decl ::= "tag" identifier ("(" parameter-type-list? ")")? ";"
export-decl ::= "export" export-kind identifier "as" string-literal ";"
export-kind ::= "func" | "global" | "memory" | "table" | "tag"
start-decl ::= "start" identifier ";"

param-decl ::= "param" identifier ":" param-type "=" param-literal ";"
comptime-for-decl ::= "comptime" "for" identifier "in" expression ".." expression
"{" declaration* "}"
comptime-if-decl ::= "comptime" "if" "(" expression ")" "{" declaration* "}"
("else" (comptime-if-decl | "{" declaration* "}"))?

The two strings in an import declaration are its WebAssembly module and field names. The string in an export declaration is its WebAssembly export name. export-kind MUST match the referenced declaration's namespace. There are no implicit ABI names.

A param declaration is a fourth kind of top-level declaration, alongside the runtime declaration kinds above — it introduces no runtime state of its own and is fully consumed before name resolution runs. comptime for and inline if are likewise declarations at module scope (and statements inside a function body). All three, together with [<...>] identifier paste and str!(...) stringize, are specified fully in section 13.

The initial value of an immutable or mutable global MUST be a WebAssembly constant expression permitted by the selected target features.

A module MAY contain at most one start declaration. Its name MUST resolve to a function, including an imported function, with no parameters and result (). It is emitted in the module's WebAssembly start section and is invoked during module instantiation.

Every memory declaration and import states its initial page count and optional maximum page count explicitly. The initial count MUST be non-negative and the maximum, when present, MUST not be smaller than the initial count. No implicit memory minimum exists.

A module MAY declare or import any number of memories. Every memory operation MUST name its target memory in its immediate list; Reed has no implicit default memory. Memory indexes and addresses are i32; memory64 is not supported.

A function-type declaration emits a named WebAssembly function type and is a heap type. &Name and ?Name are valid only when Name resolves to a function-type declaration. Function-reference assignability is invariant: named function types match only themselves. The parameters and results of a function assigned to a function reference MUST exactly match the named function type's signature.

A type alias declaration introduces a transparent name for one complete value or reference type. Aliases emit no WebAssembly type definition and create no nominal identity: assignability, instruction selection, and ABI shape use the fully expanded target type. Aliases may refer to declarations or aliases written later, but an alias cycle is an error. An alias name is valid as a complete type; it is not itself a heap-type, so &Alias is invalid. Write an alias to &T or ?T and then use that alias without another sigil.

In a heap-type position, an identifier MUST resolve to a declared struct, array, or function type, never a type alias.

A data declaration decodes its string literal to UTF-8 bytes. Without at, it emits a passive WebAssembly data segment. data Name at offset = value; emits an active segment targeting the module's sole memory; it is a lowering error when the module has zero or more than one memory. The offset is an i32 constant expression. The data name is in a distinct data namespace and is used by catalog operations such as memory.init and data.drop.

An elem declaration emits a WebAssembly element segment and, unlike a data declaration, is always passive — there is no at offset alternative. An active placement at a non-zero offset is achieved compositionally instead: declare the table with null entries and use Table.init<Name>(...) (section 10) to copy from the passive segment at runtime. Its type MUST be a nullable reference to a named function type (?FunctionType), the same requirement a table declaration's own type carries (section 10). Its entries follow the table-entry-list grammar above unchanged — null or &function_name — and each &function_name MUST exactly match the declared function type's signature. The elem name occupies a distinct elem namespace, parallel to the data namespace above, and is used by catalog operations such as table.init and elem.drop.

5.1. Modules and Visibility

A compilation unit is a set of source files compiled together into a single WebAssembly module: a root file, plus every file it transitively imports.

Every compilation unit also includes Reed's core library by default. The core library is not named by source text, but its declarations are present before user declarations are resolved. Its public methods on i32 and i64 provide the integer method-postfix operations listed in section 7. They occupy the same function namespace as user declarations, so a user declaration with the same qualified name (for example i32.clz) is a duplicate declaration, not an override. Implementations MAY suppress diagnostics that would only complain that unused default-core declarations are unused.

A use declaration names another Reed source file and the names taken from it. It is entirely distinct from a WebAssembly import, which names a host module and field; the two use different keywords and there is no spelling shared between them.

A module path is a .-separated sequence of identifiers. The last segment names the file, and each earlier segment names a directory containing it, so use a.b.c.{name}; denotes the file a/b/c.reed. The .reed extension MUST NOT be written, and the path MUST NOT be quoted: a Reed source file's name is required to be a valid identifier so that every path is expressible this way.

A path segment MAY be a keyword. A segment names a file, not a declaration, and a file may legitimately be called array.reed or table.reed; the position is unambiguous, since nothing but the path may appear between use and the item list. An item, by contrast, names a declaration and MUST be an identifier.

use util.{add};                // one name
use util.{add, Point};         // several names
use util.*;                    // every pub name
use util.{add as plus};        // under a different name in this file
use net.http.{get};            // the file net/http.reed

The item list MUST be braced or *. There is no brace-less single-item form: use util.add; is invalid, because it is indistinguishable from a path naming one more directory level.

A relative path is resolved against the directory of the importing file, not the compiler's working directory, so a use denotes the same file regardless of where the compiler is invoked. Two paths denoting the same file MUST resolve to the same file: the file is parsed once and its declarations contributed once, however many files name it.

A path whose first segment is std instead names a module of Reed's standard library (section 5.2) and is not resolved against the filesystem at all: use std.math.*;. A user source file MUST NOT be named std.reed or core.reed, since either would be unreachable — std is reserved by the rule above, and core names the default core library.

Aliasing

A use item MAY be written original as local, which makes the declaration visible in the importing file under local in addition to nothing else: the declaration itself is not renamed. A compilation unit lowers to one module in which every name is unique (below), and an implementation MUST NOT rename a declaration to satisfy an alias. An alias is therefore a property of the importing file's view, not of the declaration.

local MUST obey section 3's naming convention for the kind of declaration it names, and MUST NOT collide with any other name visible in the importing file. An alias does not shadow a local variable or parameter: within a function body, an identifier that resolves as a local resolves as that local, exactly as it would without the alias.

The pub modifier makes a declaration visible to other files in the compilation unit. It MAY be applied to any declaration that introduces a name: func, global, struct, array, type, table, memory, tag, param, const, enum, macro, and comptime func. Applying it to a declaration that introduces no name (a WebAssembly import, an export, start, data, elem, or a comptime control-flow form) is an error. A use declaration is the single exception: pub on one is permitted and means re-export, defined below.

pub and export are independent and MUST NOT imply one another:

  • pub controls visibility within the compilation unit. It is consumed at compile time and contributes nothing to the emitted module.
  • export (section 11) controls visibility to the WebAssembly host, and is the only thing that produces a WebAssembly export.

A pub func that is not also exported is therefore callable from another Reed file and absent from the emitted module's export section.

Importing a name that the named file does not declare is an error. Importing a name it declares without pub is a distinct error, and an implementation SHOULD report the two differently, since the corrective action differs.

Visibility is per-file, and imports are not transitive

A name is visible in a file if and only if that file declares it, or imports it directly. Importing a file does not make visible the names that file itself imported. So if a.reed imports b.reed, and b.reed imports c.reed, a name declared in c.reed is not visible in a.reed unless a.reed imports c.reed too, or b.reed re-exports it.

This holds even though a compilation unit lowers to a single WebAssembly module with one flat namespace (above), in which every declaration of every file is physically present. Visibility is a compile-time property of each file, not of the merged unit, and an implementation MUST enforce it per file rather than treating presence in the unit as reachability.

A use of a name that is present in the unit but not visible in the using file is an error. An implementation SHOULD name the file that declares it, since the corrective action is to import that file.

Re-exporting: pub use

A use declaration MAY carry pub, which makes the names it brings in part of the importing file's own pub surface. A file importing that file then sees them, indistinguishably from names it declared itself.

pub use deep.{add};             // re-export one name
pub use deep.*;                 // re-export deep.reed's whole pub surface

A pub use re-exports exactly the names it imports, which are by definition pub in the imported file. It therefore MUST NOT widen visibility: a declaration that is not pub in the file that declares it is not re-exportable, and neither is a name the re-exporting file merely imported through some other, non-pub import.

Re-exports compose: a name may cross any number of import edges provided every edge is a pub use. It stops at the first edge that is not.

pub on a WebAssembly import remains an error. Such an import's name is supplied by the host rather than declared by the file, and there is no other file for it to be re-exported to.

Because a compilation unit lowers to one WebAssembly module whose WAT names are derived from source names (section 15), a name MUST be unique across the entire compilation unit, not merely within one file. An implementation MUST NOT resolve a collision by renaming.

Declarations from an imported file are ordered before those of the file that imports it. This is observable only for macros, which are order-dependent (section 14): an imported macro is therefore usable throughout the importing file.

Cycles

The import graph MAY contain cycles. A file is parsed once and its declarations contributed once however many files reach it, so a cycle imposes no ordering requirement on the declarations it contains: a compilation unit is a set of files merged into one module, and merging is order-independent.

Visibility (above) is unaffected: a name imported across an edge that closes a cycle is subject to exactly the same rules as any other, including the pub requirement and the non-transitivity rule. An implementation MUST NOT relax either inside a cycle.

Macros are the one exception, and it is not a special rule for cycles but a consequence of an existing one. Macro expansion is a single forward pass over the merged module (section 14), so a macro MUST be declared before the point at which it is used. Within a cycle no such ordering exists in both directions, so a macro declared in one file of a cycle and used in another is an error. An implementation SHOULD distinguish this from an ordinary used-before-declared mistake, since within one file the fix is to move the declaration and within a cycle no placement works.

An implementation MAY impose an upper bound on the number of files in a unit, and MUST NOT fail to terminate on a cyclic graph.

5.1 Annotations

An annotation attaches information to a module that the module's own execution cannot observe. Every annotation is introduced by @ followed by its name, mirroring the WebAssembly text format's own (@name ...) annotation syntax. An annotation's name is an ordinary identifier and MUST NOT be reserved; an implementation MUST report a syntax error naming the unrecognized annotation, rather than a generic parse failure, for any @name it does not implement.

Annotations divide into two kinds, and the distinction is normative:

  • A descriptive annotation attaches information a conforming program cannot observe. @custom, @likely, @unlikely, and @deprecated are descriptive. An implementation MUST NOT let one change which programs are valid, what any expression evaluates to, or the order in which anything is evaluated. A descriptive annotation MAY cause a non-blocking diagnostic, which is information about the program rather than a change to it.
  • A directive annotation changes how a construct is compiled, and MAY therefore constrain what the source may do with it. @inline is directive. A directive annotation MUST still preserve the observable behavior of every program it accepts: it MAY reject a program, but MUST NOT make an accepted program compute a different result.

Custom sections

custom-annotation ::= "@" "custom" "(" string-literal ("," string-literal)? ")"
custom-placement? ";"
custom-placement ::= ("before" | "after") ("code" | "data" | "first" | "last")

@custom("name", "payload"); emits a WebAssembly custom section. The first string is the section name and the second is its contents; both are byte strings, not identifiers, and an implementation MUST escape them as required by the text format rather than emitting them verbatim. Omitting the payload emits a section with empty contents.

A custom section name is in no namespace: it MUST NOT be resolved, MUST NOT collide with any declaration, and MAY be declared more than once with the same name, since duplicate custom sections are meaningful in the binary format. pub MUST NOT be applied to it, as there is no name to make visible.

The optional placement clause positions the section relative to the module's standard sections. With no clause, the placement is implementation-defined.

Branch hints

branch-hint ::= "@" ("likely" | "unlikely")

A branch hint precedes an if statement and states which way its condition is expected to go. It MUST attach to an if; an implementation MUST report a syntax error for one in any other position, since a hint describes a specific branch instruction. Every condition form of section 9, including the narrowing forms, accepts a hint.

A hint is advisory. It MUST NOT change the program's behavior; it lowers to an entry in the metadata.code.branch_hint custom section, which an engine MAY consult and MAY ignore. There are exactly two hints rather than a probability, because the section encodes one bit per branch.

Inline functions

inline-annotation ::= "@" "inline"

@inline precedes a func declaration (before any pub) and requires every call to that function to be replaced by its body rather than compiled as a call. This includes method functions: both receiver.m(args...) and T.m(receiver, args...) call spellings MUST inline an @inline method. An implementation MUST report a syntax error for @inline on any other declaration, including an imported function, whose body belongs to the host.

An inlined call MUST compute exactly what a call would have. In particular each argument expression MUST be evaluated exactly once, in source order, before the body runs, and a return within the inlined body MUST transfer control to the end of the substituted body rather than out of the enclosing function. For method-call syntax, the receiver is parameter zero and MUST be evaluated before the explicit method arguments.

A function marked @inline MUST NOT be reachable other than by a direct call. Accordingly an implementation MUST report a diagnostic when such a function is exported, named by a start declaration, referenced by &name, or placed in a table or elem segment; and MUST NOT emit it as a function in the module, since nothing can reach it. Its body MUST still be checked, so an error in an @inline function that is never called is still reported.

An @inline function MUST NOT be recursive, directly or through any chain of @inline calls, since substitution would not terminate; this is a type error at the offending call. It MAY call, and be called by, ordinary functions. Its body is subject to the same rule as a control expression with a non-() result: every path MUST produce the result, so a non-unit @inline function MUST NOT be able to reach the end of its body.

Whether a non-annotated function is inlined is an implementation-defined optimization choice and is not observable. @inline is a requirement, not a hint.

Deprecation

deprecated-annotation ::= "@" "deprecated" ( "(" string-literal ")" )?

@deprecated precedes a func declaration and marks it as scheduled for removal. The optional string is a migration note naming what to use instead. An implementation MUST report a syntax error for @deprecated on any other declaration.

@deprecated is descriptive: it MUST NOT change what the module contains, which programs are valid, or what any expression evaluates to. A deprecated function is called, referenced, exported, and inlined exactly as it would be without the annotation.

An implementation SHOULD report a non-blocking diagnostic at each use of a deprecated function, quoting the note when one was supplied. A use is any construct that names the function: a call in either method spelling, &name, an export, a start declaration, a table or elem entry, a global initializer, and the corresponding escape-hatch forms. An implementation SHOULD NOT report a use that appears inside the body of a function that is itself deprecated, since no migration is available there.

An implementation MAY apply @deprecated to more declaration kinds than func. It MUST surface the annotation, and its note, in any reference documentation it generates.

@deprecated and @inline are independent and MAY appear in either order.

5.2. The Standard Library

Reed defines a standard library: a set of named modules, written in Reed, supplied by the implementation and addressed by the reserved path root std.

use std.math.*;                   // every pub name in the math module
use std.list.{IntList};           // one name

The segments after std name a module. An implementation MUST reject a use naming a module it does not provide, and the diagnostic SHOULD list the modules that are available. A std path is a module identity in its own right: it is never resolved against the filesystem, and two uses of the same module name, from any files, denote one module contributed once — the same rule ordinary paths follow. Because std is reserved as a path root, a user source file named std.reed is forbidden (section 5.1).

The standard library differs from the core library (section 5.1) in three ways, and the distinction is normative rather than editorial:

  1. Core is injected; standard modules are imported. Core's declarations are present in every compilation unit with no source text naming them. A standard library module contributes nothing to a unit that does not import it.
  2. Core wraps instructions; standard modules are ordinary Reed. Every core declaration is @inline and wraps a single WebAssembly instruction. A standard library module has no such restriction: it MAY declare types, use recursion, and allocate.
  3. Core is never emitted; a reached standard function is. An implementation MUST emit a standard library function that the module can reach, and MUST NOT emit one it cannot. Reachability is transitive from the user's own declarations and from every declaration-position use in section 11's sense (an export, start, a table or elem entry, a global initializer).

Standard library declarations occupy the same flat namespace as user declarations and as each other (section 5.1). A user declaration whose name collides with one in an imported standard module is therefore a duplicate declaration, exactly as a collision between two user files is; an implementation MUST NOT resolve such a collision by renaming. It follows that two standard library modules MUST NOT declare the same name, since a program importing both could not otherwise be compiled at all.

Both the core library and every standard library module are library files. An implementation:

  • MUST NOT report a diagnostic about a library file's own contents to a user who merely imported it, other than one that prevents compiling;
  • MUST NOT report a library declaration as unused (extending the allowance section 5.1 grants the core library);
  • SHOULD exclude library files from generated documentation by default, while providing a way to include them.

This specification does not fix the module list, the functions in each, or their signatures. Those are the implementation's to define and extend; what is normative here is the reserved std path root, the namespace rule, the emission rule, and the library-file treatment above.

5.3. Enumerations

An enum declaration names a group of related compile-time integer constants and a transparent type alias for the integer type that holds them.

enum-decl ::= "enum" identifier (":" enum-repr)? "{" enum-member-list? "}"
enum-repr ::= packed-field-width
enum-member-list ::= enum-member ("," enum-member)* ","?
enum-member ::= identifier ("=" expression)?

The representation is a bit width, spelled exactly as a packed struct field's type is (section 6.2): iN for a signed representation and uN for an unsigned one, with i32 and i64 being the widths 32 and 64. bool MUST be rejected: it is a transparent alias of i32 (section 4.1), so an enum over it could hold nothing u1 cannot. A width outside 1..64 MUST be rejected.

The representation defaults to i32. An enum's name and every member's name MUST be PascalCase (section 3). A member's name MUST be unique within its enum; members of two different enums MAY share a name, since a member is always named through its enum.

Each member's value is a compile-time-known integer, evaluated by the same rules a const initializer is (section 13.2) and range-checked at the declared representation's width -- so enum Color : u4 { Big = 20 } is an error. An implementation SHOULD name the width and its range, since the width is what the author chose and what constrains the value.

A compile-time integer is signed 64-bit (section 13), so two representations cannot express their full range in source: the upper half of u64, and i64's most negative value, whose magnitude has no literal spelling. This is a pre-existing property of compile-time evaluation rather than a rule about enums. A member with no = expression takes the previous member's value plus one, and the first such member takes 0. A member's initializer MAY name an earlier member of the same enum by its bare member name, and MAY name any param, const, or enum member already in scope. Values need not be distinct or increasing; two members MAY denote the same value.

A member is referenced as EnumName.MemberName, and that expression is a compile-time-known value of the representation type. It is therefore valid everywhere a const is: an ordinary expression, a global initializer, a switch case value, a comptime if condition, a comptime for bound, and the token splice forms of section 13. EnumName alone is not an expression.

The enum's name is also a transparent type alias for its representation's value type (section 5's type-alias rule): Color used as a type means exactly i32, or i64 for a representation wider than 32 bits, with no nominal identity, no distinct ABI shape, and no implicit conversion to police. An implementation MUST NOT emit a WebAssembly type definition for an enum.

A narrow representation is a storage width, not a distinct value type -- the same split section 4.1 draws for i8/i16, which are storage types that read as i32. So the width is observable in exactly the two positions where a width is the subject, and the value type governs everywhere else:

  • A packed struct field (section 6.2) whose type names an enum occupies exactly the enum's declared width.
  • A normal struct field or array element (section 6) whose type names an enum is stored at the narrowest storage type that holds the width: i8 for 1..8 bits, i16 for 9..16, and otherwise the value type itself.
  • Everywhere else -- a parameter, a local, a return type, a global, and the type of a EnumName.MemberName expression -- the enum name means its value type.

In both field positions the enum's representation also fixes how a read extends, so the .s/.u suffix section 6.1 otherwise requires on a narrow field MUST be optional there, defaulting to the declared signedness. An explicit suffix remains valid and takes precedence. This is what keeps the alias transparent: an implementation MUST NOT let a declared width change what the enum name means in a value position.

This is deliberately a C-style enum, not a sum type. It introduces no runtime representation of its own, so an enum-typed value is an ordinary integer: an arbitrary integer MAY be assigned where an enum type is expected, and an implementation MUST NOT reject that, nor check that a switch over enum members is exhaustive. A future nominal or algebraic enumeration would be a separate construct.

pub applies to an enum, and makes its members visible along with its name: they are one declaration, and importing a type whose members were not visible would be useless.

6. Structures and GC Subtyping

A struct declaration defines a nominal WebAssembly GC struct type. A struct with one or more declared children is emitted as non-final. A struct with a parent is emitted as a WebAssembly subtype. All other structs are final; membership in a recursive group does not change finality.

struct Child : Parent {
own: i32,
}

means that Child contains all fields of Parent, in parent-first order, followed by own. The source MUST NOT repeat inherited fields. The compiler MUST flatten inherited fields when producing the WebAssembly struct definition and MUST mark the necessary parent and child types as sub.

Each parent field's type and mutability are inherited unchanged. A child MAY not shadow an inherited field name. Parent links MUST be acyclic.

An i8 or i16 field is packed WebAssembly storage. Its declaration omits a packed keyword. A packed-field read MUST use an explicit .s or .u suffix, for example pixel->red.u or pixel->delta.s; it yields an i32 using signed or unsigned extension respectively. A packed field cannot be read without that suffix. A packed-field assignment accepts an i32 and stores its low 8 or 16 bits. Non-packed fields do not accept .s or .u suffixes.

The compiler constructs the directed type-dependency graph from declared structs, arrays, and function types. An edge exists for a field, array element, function parameter/result, or declared parent type referring to another declared type. Each strongly connected component is emitted as one recursive type group; singleton components without a self-edge are emitted outside a group. Components are emitted in dependency order. The user does not write recursive type-group annotations.

That grouping is subject to one overriding requirement. WebAssembly canonicalizes recursive type groups structurally, and names carry no identity, so two separately emitted groups with the same structure denote the same runtime type. Emitting each component independently therefore collapses distinct declarations whose fields coincide -- for example two children of one parent whose own fields have identical types -- which contradicts the nominal identity this section requires and makes a section 9 type test match the wrong declaration.

An implementation MUST NOT emit two declared types that canonicalize to the same WebAssembly type. Where the component decomposition above would do so, the affected components MUST be emitted together in a single recursive group, which makes their identity positional and keeps them distinct. An implementation MAY group more components than strictly necessary to satisfy this rule, and MAY group a contiguous span of components in dependency order to preserve that order. Grouping MUST NOT otherwise change field order, mutability, inheritance, or subtyping.

Struct values are allocated explicitly:

new Name { field: expression, ... }

A struct construction has the non-null type &Name.

An empty field-initializer list, new Name {}, is a semantic special case, not a distinct grammar production. When Name has no declared fields (including inherited ones), it is the ordinary construction above with zero initializers. When Name has one or more fields, new Name {} instead requests default-initialization of every field: no field-initializer expression is evaluated, and the construction lowers to struct.new_default instead of struct.new. Every field's type, including an inherited one, MUST be defaultable -- a numeric type, or a nullable ?T reference -- for Name to be default-initializable this way. A non-null &T field has no default value and makes the whole struct ineligible; the compiler MUST reject new Name {} with a compile error naming the offending field.

Every field, including inherited fields, MUST be initialized exactly once, except in the default-initialization case above, where every field is defaulted instead and no explicit initializer may be written at all (the two forms are distinguished purely by whether the field-initializer list is empty). Unknown and duplicate field initializers are errors. Field initializer expressions are evaluated once in lexical source order. The compiler MUST retain and reorder those values into parent-first field order before emitting struct.new; that reordering MUST NOT change observable evaluation order. Fields are read with reference->field. The receiver MUST have a non-null declared-struct reference type; inherited names resolve to the nearest declared field. Assigning to a field is permitted only when the field was declared mut.

6.1 Arrays

An array declaration defines a nominal WebAssembly GC array type with one element field. Its element type is immutable unless preceded by mut.

array I32Values { mut i32 }

let values: &I32Values = new I32Values { 10, 20, 30 };

An array declaration has no parent type and is emitted as a final WebAssembly array type. Like structs, declared arrays are included automatically in any required recursive type group.

new Name { expression, ... } constructs an array when Name resolves to an array declaration. Its expressions are evaluated left to right; every value MUST be assignable to the array element type. It lowers to array.new_fixed with Name and the expression count as instruction immediates. An empty initializer is valid and lowers to array.new_fixed Name 0. An array construction has the non-null type &Name.

new Name [ count ] {} is a second, distinct array-construction alternative (section 7's array-construction grammar), used instead of the brace-list form above whenever a runtime count is needed rather than an explicit list of values -- a bracketed count cannot be combined with a non-empty brace list, and an empty brace list without a bracketed count keeps its existing meaning above (a zero-length array.new_fixed), so the two forms never overlap or require post-hoc disambiguation. It constructs a count-length array with every element default-initialized and lowers to array.new_default. count MUST be an i32 expression and is evaluated exactly once. The element type MUST be defaultable -- a numeric type, or a nullable ?T reference -- using the same rule as struct default-initialization above; a non-null &T element type has no default value and makes Name ineligible, a compile error. A construction produced this way also has the non-null type &Name.

new Name [ count ] { value } is a third alternative: a count-length array with every element set to the single value, lowering to array.new. It is distinguished from the default-init form directly above purely by the brace list being non-empty, and from the explicit list form by the presence of the [ count ]. At most one element may appear between the braces; two or more is a compile error, since a count alongside a full list is either redundant or contradictory.

value MUST be assignable to the element type. Unlike the default-init form, the element type need NOT be defaultable: an explicit value is supplied, so a non-null &T element type is valid here, and this is the only construction that fills such an array without listing every element. value is evaluated exactly once regardless of count, so every element of a reference-typed array built this way refers to the same object.

count and value are each evaluated exactly once, count first, matching the left-to-right rule everywhere else in this specification -- the fact that the underlying array.new instruction expects its stack operands in the opposite order is a lowering detail with no observable effect on evaluation order, exactly as for the segment-sourced forms below. A construction produced this way has the non-null type &Name.

new Name [ count ] data < D > ( offset ) and new Name [ count ] elem < E > ( offset ) are two further array-construction alternatives (section 7's array-construction grammar), each sourcing the new array's contents from a declared segment instead of default-initializing, filling with a single value, or listing elements explicitly. D/E are declaration references resolved at compile time, not values to evaluate -- the same <Name> immediate convention used elsewhere in the escape hatch (section 12) and by .copy<...>(...)/.init<...>(...) (section 10). count and offset are each i32 and evaluated exactly once, in that left-to-right source order, matching every other left-to-right evaluation rule in this specification -- the fact that the underlying array.new_data/array.new_elem instructions expect their stack operands in the opposite order is a lowering detail with no observable effect on evaluation order.

  • new Name [ count ] data < D > ( offset ) requires Name's element type to be a value type (numeric or packed i8/i16) and D to name a declared data segment; it lowers to array.new_data. A reference-typed element is a compile error, since array.new_data cannot produce references.
  • new Name [ count ] elem < E > ( offset ) requires Name's element type to be a reference type assignable from E's declared elem type (always a nullable reference to E's function type, per the elem declaration above) and E to name a declared elem segment; it lowers to array.new_elem.

Both alternatives produce the non-null type &Name, the same as every other array-construction form above.

Array elements use C-style square-bracket syntax:

let second: i32 = samples[1];
samples[1] = 34;

An indexed read requires a non-null declared-array reference and an i32 index. It yields the array element type and lowers to array.get. An indexed assignment has the same receiver and index requirements; its right-hand expression must yield one value assignable to the element type, and the element type MUST be declared mut. It lowers to array.set.

Reads evaluate the receiver and then the index, each exactly once. Assignments evaluate the receiver, index, and right-hand expression in that order, each exactly once. Both operations have WebAssembly's native out-of-bounds trap behavior. The initial surface syntax provides simple = assignment only; compound array assignments such as += are not part of the language.

The unary # operator returns an array's current length:

let count: i32 = #samples;

Its operand MUST be a non-null declared-array reference. It evaluates that operand exactly once, returns i32, and lowers to array.len.

An i8 or i16 array element is packed storage. Its indexed read MUST use an explicit .s or .u suffix, for example samples[index].u; it yields an i32 using signed or unsigned extension. Its assignment accepts an i32 and stores its low 8 or 16 bits when the element is mut. A packed array element cannot be read without a suffix, and a non-packed indexed read cannot use one.

6.2 Packed Structures

A packed struct declares a value type whose fields occupy bit ranges of a single integer. Unlike a struct (section 6), it allocates nothing: a packed value is an ordinary WebAssembly scalar, passed and stored by value.

packed-struct-decl ::= "packed" "struct" identifier ":" packed-repr
"{" packed-field-list? "}"
packed-repr ::= "i31" | "i32" | "i64"
packed-field-list ::= packed-field ("," packed-field)* ","?
packed-field ::= identifier ":" packed-field-type
packed-field-type ::= packed-field-width | identifier
packed-field-width ::= "bool" | ("i" | "u") integer-literal

The representation is mandatory: it states how many bits are available and how the value is carried, and both are observable, so neither may be inferred.

A field's type is bool (one bit), an explicitly signed iN/unsigned uN field of N bits, or the name of an enum (section 5.3), which contributes its declared width and signedness. N MUST be at least 1 and MUST NOT exceed the representation's width. A field name MUST be snake_case and MUST be unique within its packed struct. The declared name MUST be PascalCase.

An identifier in this position that does not name an enum MUST be rejected; in particular it is not a way to nest one packed struct inside another.

Fields occupy bit ranges in declaration order, least-significant first, with no padding: the first field occupies bits [0, N0), the second [N0, N0+N1), and so on. The sum of every field's width MUST NOT exceed the representation's bit capacity: 31 for i31, 32 for i32, 64 for i64. Unoccupied high bits read as zero in a value the implementation constructs, but are not otherwise constrained.

A packed struct MAY declare no fields, and MAY leave capacity unused. It has no parent, no subtyping, and no inheritance.

Representation

i32 and i64 are the value types of the same name. i31 is the WebAssembly (ref i31) reference: a packed value with an i31 representation is a non-null i31 reference, which is what allows it to be stored in an ?any/&eq field, array element, or table entry without allocating. Its capacity is 31 bits accordingly.

A packed struct's type is nominal: two packed structs with identical fields are distinct types, and neither is assignable to the other, nor to its representation type, nor from it. This is the whole point of the construct -- an arbitrary integer flowing into a field extraction would read unrelated bits as structured data.

The single exception, and it is one-way: a packed struct with an i31 representation is assignable to &i31, &eq, &any, and their nullable forms, since it is such a reference at runtime. No conversion exists in the other direction, because two i31-represented packed structs are indistinguishable at runtime and a cast between them could not be checked. Round-tripping is written explicitly, as as conversions in both directions.

Constructing and reading

A packed value is constructed with the same new Name { field: expression, ... } syntax a struct uses (section 6), including its rules: every field initialized exactly once, evaluated in lexical source order, unknown and duplicate initializers rejected. new Name {} on a packed struct with fields default-initializes every field to zero (false for a bool field), which is always possible since every packed field type is numeric.

Each initializer is an i32 for a field of 32 bits or fewer and an i64 for a wider one, and its value is truncated to the field's width: only the low N bits are stored. Truncation is never an error, matching a packed i8/i16 struct field assignment (section 6) and WebAssembly's own store instructions. An implementation SHOULD report a compile-time-known value that does not fit, as a suppressible warning that does not change what is stored — the value is already known there, so silence is a choice rather than a limitation. A value not known until run time MUST NOT be reported.

A field is read with value->field, yielding an i32 for a field of 32 bits or fewer and an i64 for a wider one. The result is sign-extended for an iN field and zero-extended for a uN field; a bool field yields 0 or 1. Signedness comes from the declaration, so a packed-field read MUST NOT take the .s/.u suffix a GC packed field read requires (section 6) -- there, the same i8 storage genuinely admits both readings, and here the source has already chosen.

Assignment to a packed field, place->field = expression, replaces that field's bits and leaves every other field unchanged. Because a packed value is a value and not a reference, its place MUST be one that can be written back: a mutable local (var) or a mutable global. A packed value reached through a GC struct field, an array element, or a function parameter is not assignable this way; the value is rebuilt with new instead.

Conversions

value as i32 / value as i64 / value as &i31 yields the packed value's underlying bits, and bits as Name reinterprets an integer as a packed value of that type. Both directions are explicit: this is a reinterpretation, not a numeric conversion, and neither direction may be implicit. The source type of bits as Name MUST be the packed struct's representation type. Neither direction is checked, since every bit pattern is a valid packed value.

Testing for a packed struct

A packed struct with an i31 representation MAY appear as the target of a type test -- value is &Name in either the narrowing or the expression form (section 9), and a case &Name switch pattern (section 8). The test compiles exactly as the corresponding &i31 test does, since that is what the value is at runtime, but the type it narrows to is Name rather than &i31, so the value's fields are readable directly inside the matched block.

The nullable spelling ?Name is not accepted in these positions, and neither is a packed struct whose representation is i32 or i64: those are plain integers with no runtime identity, so an implementation MUST reject such a test rather than answering it.

This test is a narrowing convenience, not a discriminator, and the distinction is normative. Two i31-represented packed structs are indistinguishable at runtime, exactly as the one-way assignability rule above states, so the test answers "is this an i31?" A value of a different i31-represented packed struct therefore matches, and its bits are then read through the tested struct's layout. Where several such types share a slot, the program must carry its own discriminator; the language cannot supply one.

7. Functions, Locals, and Expressions

Function parameters are immutable locals. A function body may declare locals:

let name: Type = expression;
var name: Type = expression;
let (first, second): (i32, i64) = expression;

let bindings are immutable; var bindings may be assigned with name = expression;. A single binding requires exactly one result; a parenthesized binding requires a result sequence of equal arity. Each result MUST be assignable to its corresponding declared type. Local names are scoped to their enclosing block and may shadow an outer local.

Reading a PascalCase global lowers to global.get. Assigning to one lowers to global.set and is valid only when the global was declared mut. A global cannot be shadowed by a local because their required spellings differ.

The expression grammar below lists the initial high-level expressions. The parser resolves binary precedence from highest to lowest in the order shown.

expression ::= ternary (("is" "null") | ("is" reference-type))?
ternary ::= logical-or ("?" ternary ":" ternary)?
logical-or ::= logical-and ("||" logical-and)*
logical-and ::= bitwise-or ("&&" bitwise-or)*
bitwise-or ::= bitwise-xor ("|" bitwise-xor)*
bitwise-xor ::= bitwise-and ("^" bitwise-and)*
bitwise-and ::= equality ("&" equality)*
equality ::= comparison (("==" | "!=") comparison)*
comparison ::= shift (("<" | "<=" | ">" | ">=") shift)*
shift ::= sum (("<<" | ">>" | ">>>") sum)*
sum ::= product (("+" | "-") product)*
product ::= cast-expression (("*" | "/" | "%") cast-expression)*
cast-expression ::= unary ("as" type storage-read?)*
unary ::= ("!" | "-" | "~" | "#") unary | "&" function-identifier | postfix
postfix ::= primary ( "(" expression-list? ")"
| "->" identifier storage-read?
| "[" expression "]" storage-read?
| "." identifier "(" expression-list? ")"
| "." identifier "<" identifier ">"
"(" expression-list? ")" )*
storage-read ::= ".s" | ".u"
primary ::= literal | identifier | "(" expression ")" | "(" assignment-expr ")"
| tuple | struct-construction | array-construction
| control-expression | wasm-operation | "unreachable"
tuple ::= "(" expression "," expression-list ")"
assignment-expr ::= identifier "=" expression
struct-construction ::= "new" identifier "{" field-initializer-list? "}"
array-construction ::= "new" identifier "{" expression-list? "}"
| "new" identifier "[" expression "]" "{" "}"
| "new" identifier "[" expression "]" "{" expression ","? "}"
| "new" identifier "[" expression "]"
"data" "<" identifier ">" "(" expression ")"
| "new" identifier "[" expression "]"
"elem" "<" identifier ">" "(" expression ")"
field-initializer-list ::= field-initializer ("," field-initializer)* ","?
field-initializer ::= identifier ":" expression

The ternary condition ? then : else is right-associative and, among the operators built from logical-or downward, the loosest-binding: looser than ||, so a || b ? c : d parses as (a || b) ? c : d, and a ? b : c ? d : e parses as a ? b : (c ? d : e). Both branches evaluate through the same expected-type-propagation rule as any other expression position; the compiler MUST reject a ternary whose branches do not agree on a single result type. It lowers to select and, critically, is not short-circuiting: unlike if/else and unlike &&/||, WebAssembly's select evaluates both the then and else operand before choosing between them. The condition, then, and else operands are each evaluated exactly once, in that left-to-right source order, even though select itself expects its condition operand last on the WebAssembly operand stack — despite reading visually like if/else, both branches' side effects always happen.

The trailing is null / is reference-type suffix binds looser still than the ternary itself, and is checked only once, at the very top of expression: a ternary's own then/else branches recurse into ternary, not into expression, so an unparenthesized is cannot appear nested inside a ternary's condition or either branch (cond ? a is null : b is a syntax error; write cond ? (a is null) : b).

(identifier = expression) is an assignment used as an expression rather than a statement — always written parenthesized, distinguishing it from the unparenthesized statement form identifier = expression; (section 8), which still lowers to local.set/global.set and produces no value. The parenthesized expression form is valid only when identifier names a mutable local var: WebAssembly has no global.tee, struct.tee, or array.tee, only local.tee, so an assignment-as-expression to a global, a struct field, or an array element is a compile error rather than a silently different lowering. On a local var it lowers to local.tee, which both performs the write and yields the assigned value as the expression's result.

The is forms are also usable as an ordinary i32-valued boolean expression anywhere an expression is expected, not only inside an if-condition — see section 9 for the full grammar, lowering, and the flow-narrowing behavior that remains exclusive to the if-condition position.

The single & token has two roles: it begins a reference type in a type position and is bitwise-and in an expression position. These uses are unambiguous from grammar context.

The parser resolves the two new Name { ... } productions after resolving Name: a struct name requires named field initializers, while an array name requires an expression list. A name that resolves to neither is an error.

An expression is evaluated left to right. Calls evaluate arguments left to right. A call's argument count and types MUST match the function signature.

Every expression has a result arity. Literals, locals, globals, operators, field accesses, constructors, and single-result calls have arity one. A zero-result call may occur only as an expression statement. A multi-result call, tuple, or control expression may occur only as a return operand or the right side of a parenthesized local binding with identical arity. Function-call arguments, operators, field receivers, array initializers, conditions, and named struct-field initializers each require exactly one result; result sequences never expand implicitly into an argument list.

A wasm-operation has exactly the result arity declared by its catalog schema and is subject to the same zero-, single-, and multi-result placement rules as a call. In particular, a zero-result raw operation cannot be returned or used as a value, and a multi-result raw operation cannot expand implicitly into call arguments.

An expression statement must have arity zero or one. A zero-result expression leaves no stack value. A one-result expression statement lowers to the expression followed by an explicit drop; a multi-result expression statement is a type error.

unreachable is a diverging expression: it lowers to the WebAssembly unreachable instruction, which traps unconditionally, and control never continues past it. Because nothing after it runs, it is arity-polymorphic: it adopts whatever result sequence its context expects, of any arity including zero, and its operand-free form needs no contextual type to be well-formed. It may therefore appear as the initializer of a local of any type, as a return operand, as a call argument, as one branch of a construct whose other branch produces a value, or — most commonly — as the whole of an expression statement, unreachable;. Where the context expects no results at all, an expression statement consisting solely of unreachable lowers to the bare instruction with no drop (there is no value to discard).

An unreachable expression makes its enclosing completion path diverging for the purposes of section 8: a function body, control body, or switch arm whose every reachable path ends in unreachable satisfies the result-producing requirement without a return. wasm.unreachable() (section 12) remains available and produces the identical instruction; the keyword is the preferred spelling, and unlike the escape hatch it is not a call and takes no argument list.

An identifier not immediately followed by a call postfix reads a local or a global according to the naming rule in section 5. An identifier followed by a call postfix resolves to a declared function and lowers to direct call. A non-null &FunctionType expression followed by a call postfix is an indirect call and lowers to call_ref FunctionType; its arguments and results MUST exactly match that named function type. A call postfix on any other expression is a type error. &function_name creates a non-null typed function reference when context supplies a matching named function type; it lowers to ref.func. A field postfix is valid only when its receiver has a non-null declared-struct reference type and lowers to struct.get using the resolved inherited-field index.

A . beginning a postfix is a method-postfix call, except immediately after a ->identifier or [expression] postfix, where a following .s or .u is instead consumed as that postfix's storage-read suffix (above) and never as a call to a method named s or u. The method-postfix identifier MUST name either a method declared on the receiver's type (section 7.1) or one of the array-only intrinsic forms below. The default core library declares numeric methods on i32/i64: clz, ctz, popcnt (no arguments, same-type result), rotl, rotr, div_u, rem_u (one same-type argument, same-type result), and lt_u, le_u, gt_u, ge_u (one same-type argument, i32 result). On i64 only, core also declares mul_wide_s and mul_wide_u (one i64 argument, two i64 results — see below). On f32/f64, core declares abs, sqrt, ceil, floor, trunc, nearest (no arguments, same-type result), and min, max, copysign (one same-type argument, same-type result). WebAssembly has no integer min/max instruction, so core declares those two names only on f32/f64; a user may still declare a method with the same postfix name on another receiver type if its full qualified function name does not duplicate a core declaration. A method-postfix call on an unknown method name, or a method not defined for the receiver's specific type, is a type error. Each core numeric method wraps the WebAssembly instruction of the same name on the receiver's type (for example, core's i32.clz method wraps i32.clz).

mul_wide_s/mul_wide_u are the only method-postfix calls that produce more than one result: the low and high halves of the 128-bit product, in that order. They therefore appear only where multiple results are accepted, such as a destructuring let. Because their result type is a pair rather than the receiver's own type, an implementation MUST NOT use the expression's expected type to type an untyped-literal receiver, as it MAY for a method whose result type equals its receiver's; a literal receiver consequently requires an explicit type. Their add128/sub128 counterparts take four operands and have no method-postfix form, remaining available only through section 12's escape hatch.

Two further method-postfix names are reserved for a non-null declared-array receiver instead of a numeric one, dispatched ahead of declared-method lookup by name: dest.copy(dest_offset, src, src_offset, count), where src is a second non-null declared-array reference expression, lowers to array.copy, with dest's declared array type as the first instruction immediate and src's as the second. arr.fill(offset, value, count) lowers to array.fill. Both are statements only -- calling either produces no value, so neither may be used as an expression. dest_offset, src_offset, offset, and count MUST be i32; value MUST be assignable to the receiver's element type, and src's element type MUST be assignable to dest's with matching packedness. Both require the receiver's element type to be declared mut -- for .copy, the receiver is dest, so this is a single requirement on dest, not two; src's element type need not be mut. Evaluation order matches the existing indexed-write rule above: the receiver, then each argument, left to right, each exactly once. A method-postfix call naming copy or fill on a receiver that is not a non-null declared-array reference is a type error, the same as an unrecognized name on a numeric receiver.

One method-postfix form additionally carries a single <Name> immediate between the method name and its argument list: receiver.identifier "<" identifier ">" "(" expression-list? ")". Two forms on a declared memory or table name use it: the bulk-copy form, DestMem.copy<SrcMem>(dest_offset, src_offset, len) / DestTable.copy<SrcTable>(dest_offset, src_offset, len), where Name names a second declared memory or table of the same kind as the receiver; and the bulk-init form, Mem.init<SomeData>(dest_offset, src_offset, len) / Table.init<Handlers>(dest_offset, src_offset, len), where Name names a declared data or elem segment of the matching kind (section 10). In both forms Name is a declaration reference resolved at compile time, not a value to evaluate — the same role the <Name> immediate already plays in the wasm.<opcode><Name>(...) escape hatch (section 12). This immediate form is not part of the fixed numeric-method catalog above and does not apply to it; no numeric method-postfix call, and neither of the other two memory/table method-postfix forms (.grow(...), .fill(...), section 10), ever takes an immediate. A bare .drop() (no immediate, no arguments) is a third, distinct method-postfix name, valid only on a declared data or elem segment name, not a memory or table (section 10).

A cast-expression (expr as type / expr as type.s / expr as type.u) performs a numeric or any/extern reference conversion selected by the pair (operand type, target type), and, where that pair is signedness-ambiguous, by the .s/.u suffix on the target type. as sits just above unary in precedence and does not chain specially: a as i32 as i64 parses as (a as i32) as i64, each cast fully resolved before the next applies.

When the target is a numeric type, the operand MUST also be numeric, and the (operand type, target type) pair MUST name one of the following conversions; any other pair is a type error, not a silent fallback to the wasm.* escape hatch:

  • Plain as, no suffix permitted: i64 as i32 (wrap_i64), f32 as f64 (promote_f32), f64 as f32 (demote_f64). These are unambiguous, so a .s/.u suffix on the target is a type error.
  • Suffix required, naming signedness of the integer side: i32 as i64.s / i32 as i64.u (sign- or zero-extend); i32 as f32.s/.u, i32 as f64.s/.u, i64 as f32.s/.u, i64 as f64.s/.u (convert, signedness of the integer operand); f32 as i32.s/.u, f32 as i64.s/.u, f64 as i32.s/.u, f64 as i64.s/.u (trunc, signedness of the integer result; traps when the value is out of range or NaN, as in section 12). Omitting the suffix on one of these pairs is a type error naming the required suffix.
  • A cast from a numeric type to itself (i32 as i32) is a type error: it has no corresponding instruction.

A bare numeric literal operand (5 as i64.s, 1.5 as f64) works directly, without an explicit typed binding, for wrap/promote/demote (no suffix) and extend (suffix required, target i64): for these four, the source type is uniquely inferable from the target, the suffix, and the literal's own lexical kind (integer vs. floating) together -- for example, as i64.s/as i64.u also matches trunc's two candidate float sources, but a lexically integer literal can only ever mean extend. convert and trunc remain genuinely ambiguous even with the literal's lexical kind taken into account (both of trunc's candidate sources, and both of convert's, share a kind), so a literal operand there still requires an explicit typed binding to supply the source type.

Same-width bit reinterpretation is deliberately not covered by as. For the same-width int/float pairs (i32/f32, i64/f64), reinterpreting the operand's bits and numerically converting its value are two different, equally valid operations with different results, and the (operand type, target type) pair alone cannot disambiguate them the way it does for wrap/promote/demote/extend/convert/trunc above — i32 as f32 always means "convert this integer's value to the nearest float," never "reinterpret these 32 bits as a float." Bit reinterpretation remains available only through wasm.i32.reinterpret_f32, wasm.f32.reinterpret_i32, wasm.i64.reinterpret_f64, and wasm.f64.reinterpret_i64 (section 12).

When the target is ?any, &any, ?extern, or &extern, as performs the host reference bridge: the operand MUST be a nullable or non-null reference in the opposite family (extern-family for an any target, any-family for an extern target), and no .s/.u suffix is permitted. The target's written nullability (? vs &) does not change the result: the conversion always lowers to any.convert_extern or extern.convert_any, both of which produce a nullable result regardless of the operand's own nullability. An operand outside the required family, or a target heap type other than any/extern, is a type error.

Boxing an i32 into a non-null &i31 is available through as &i31, lowering to ref.i31. No .s/.u suffix is permitted — boxing has no signedness ambiguity, so a suffix is a type error. The target MUST be spelled &i31, not ?i31: ref.i31 always produces a non-null result, so a nullable target spelling is a type error naming &i31 as the required spelling.

Unboxing back to i32 is available through as i32.s/as i32.u, lowering to i31.get_s/i31.get_u respectively; the suffix is required, naming the sign extension of the result, same as the other suffix-required pairs above. The operand MUST be &i31 or ?i31 — exactly what i31.get_s/i31.get_u themselves accept (section 12) — not any broader i31-supertype family such as &eq/&any. A nullable ?i31 operand is legal and traps at runtime on null, identical to the escape-hatch form. Unboxing directly to i64, f32, or f64 is not a distinct as target; chain an existing conversion after as i32.s/as i32.u instead (some_i31 as i32.s as i64.s).

The language provides conventional infix syntax for numeric arithmetic, comparison, bitwise operations, logical negation, and shifts. Its operators are type-directed and lower to the corresponding WebAssembly operation. An operator MUST have operands whose types select exactly one WebAssembly operation; otherwise the expression is invalid. This avoids implicit coercions.

An expected type propagates into a literal, an operator expression, a tuple component, a call argument, or a constructor initializer. If that propagation does not select one operand type, the programmer MUST supply context through a typed binding, parameter, return type, cast operation, or typed wasm operation. For example, let count: i32 = 1 + 2; is valid, while 1 + 2; is not because its integer width is not determined.

The following table fixes the high-level operator mapping. Operations not in the table require wasm.<opcode> syntax.

OperatorOperand typesResultLowering and behavior
+, -, *matching i32 or i64same typeadd, sub, mul; wraps modulo width
/, %matching i32 or i64same typesigned div_s, rem_s; divide by zero and signed overflow trap
+, -, *, /matching f32 or f64same typeIEEE 754 add, sub, mul, div
-i32, i64, f32, or f64same typeinteger 0 - value or floating neg
~, &, |, ^matching i32 or i64same typeinteger bitwise operation
<<, >>, >>>matching integer operandsleft typeshl, signed shr_s, unsigned shr_u
<, <=, >, >=matching integersi32signed comparison
<, <=, >, >=matching floatsi32IEEE comparison; false for NaN
==, !=matching numeric typesi32eq/ne; float != is true for NaN
==, !=nullable references below ?eqi32ref.eq / negated ref.eq
!i32i32eqz; returns canonical 0 or 1
#non-null declared array referencei32array.len
&&, ``i32

The right operand of && is evaluated only when the left operand is nonzero; the right operand of || is evaluated only when the left operand is zero. These operators lower through structured if, not eager bitwise operations.

An assignment evaluates a field receiver before its right-hand expression. An indexed assignment evaluates its receiver, index, and right-hand expression in that order. The right-hand expression MUST yield exactly one value assignable to the place's type. An identifier place is a mutable var local or mutable global; a field place is a mutable struct field; and an indexed place is a mutable array element. The compiler MUST evaluate a field receiver exactly once and lower a field write to struct.set. It MUST evaluate an indexed receiver, index, and value exactly once in source order and lower an indexed write to array.set.

return expression; requires a result sequence with the same arity as the function result; each result must be assignable to its corresponding type. return; is valid only for a function whose result is ().

Returning a direct or indirect call has the same source semantics as every other return expression. It does not require tail-call support and MUST NOT produce a source feature error on a target without tail calls. Section 13 defines the optional tail-call output mode.

Tuple expressions and returns use parentheses, for example return (count, total);. They map directly to WebAssembly's ordered result list; tuples are not heap allocations.

7.1 Methods on Types

A function declaration's name MAY be qualified by a receiver type, making the function a method on that type:

function-name ::= (receiver-type ".")? identifier
receiver-type ::= "i32" | "i64" | "f32" | "f64" | "v128" | struct-name | array-name

The qualified form is accepted wherever a function is declared or named: a func declaration, a WebAssembly func import, an export func, a &function_name reference, a table or elem segment entry, and a start declaration. A start declaration accepts the syntax but can never name a method in a valid module, since a start function takes no parameters (section 5) while a method always has a receiver; the existing start-signature rule reports this, and no rule specific to methods is needed.

A method is an ordinary function whose name is the whole qualified spelling. It occupies the single function namespace under that name, and it is callable, referencable, exportable, and usable as a table or elem entry in exactly the ways section 7 and sections 10 through 11 already define. An implementation MUST NOT give a method a separate namespace, a distinct calling convention, or a mangled emitted name. In particular, Type.method(receiver, arguments...) is a valid ordinary direct call, and is the only way to call a method whose receiver expression is not itself a valid method receiver.

A method declaration MUST satisfy all of the following, each a resolution error otherwise:

  1. The part of the name after the . MUST be snake_case (section 3). The qualifier follows the naming rule its own type already has.
  2. The qualifier MUST name a value type or a declared struct or array. A function-type name is not a valid receiver: it names a signature, not a value with members.
  3. The method MUST declare at least one parameter, and its first parameter is the receiver. That parameter's declared type MUST be exactly the receiver type: the value type itself for a value-type qualifier, or the non-null reference to the declared type for a struct or array qualifier.
  4. In a func declaration, the first parameter MUST be named self. A func import declares parameter types without names, so this requirement does not apply to one.
  5. The method name MUST NOT be one of the built-in floating-point method-postfix names of section 7, nor one of the memory, table, data, or elem method names of section 10, since a method-postfix call resolves those without consulting declared methods and the declaration could never be reached. Integer method-postfix operations are not reserved names under this rule: they are ordinary methods from the default core library, so the qualified core names themselves are protected by the normal duplicate-declaration rule.
  6. The declaration MUST NOT carry @inline (section 5.1). An inlined call binds argument expressions as control parameters, which a receiver is not.

A method's first parameter MAY be written with the receiver shorthand, which supplies no type: &self where the receiver is a reference (a declared struct or array), and bare self where it is a value (a value type or a packed struct). The shorthand is available only in a func declaration, only in the first parameter position, and only when the function's name is qualified; anywhere else it is a resolution error. It is exactly equivalent to writing self: <receiver type>: it introduces no new parameter kind, no new calling convention, and nothing observable in the emitted module, and the long form remains valid and unchanged.

The sigil MUST match the receiver's kind, and a mismatch is a resolution error. This is a real rule rather than a courtesy: func Point.sum(self) reads as taking a Point by value and func i32.double(&self) reads as taking a reference to an integer, and Reed has neither. There is no ?self, since a nullable type has no methods at all.

self is not a reserved word. It is recognized in this one position by the absence of a following :, so an ordinary function MAY still declare a parameter named self by giving it a type in the usual way.

Declaration order is irrelevant, as for every declaration kind other than macro (section 14): a method may precede the declaration of its receiver type.

pub (section 5.1) makes a method visible to other files in the unit under its qualified name, since that is the method's name. A named use item therefore spells it qualified (use util.{Point, Point.sum};); a glob import brings it in like any other visible name. An import naming only the part after the . is an error, and the diagnostic SHOULD name the qualified spelling. Because the loader merges every file into one module before resolution, a method declared in one file and a receiver type declared in another resolve against each other with no cross-file rule of their own.

A method-postfix call receiver.name(arguments...) on a receiver of type T resolves to a declared method as follows. If T is a value type, the candidate qualifier is that type. If T is a non-null reference to a declared struct, the candidates are that struct's name followed by each ancestor's name, nearest first, so a method declared on a base struct is callable on a subtype; a subtype's method of the same name takes precedence. If T is a non-null reference to a declared array, the candidate is that array's name. Any other type — including a nullable reference — has no candidate qualifier and therefore no declared methods; a nullable receiver MUST be narrowed (section 9) first. Resolution consults only the receiver's static type, so method dispatch is static: no runtime type information is consulted, and no dispatch table is emitted.

A resolved call MUST supply exactly one argument per declared parameter after the receiver, each assignable to its parameter's type, and produces the method's declared results. It lowers to exactly the call that the equivalent Type.method(receiver, arguments...) direct call lowers to, with the receiver as the first operand. The receiver is therefore evaluated before every argument, and each operand exactly once, in source order.

An implementation MAY use a declared method's receiver type to type an untyped-literal receiver, as it MAY for a built-in method whose result type equals its receiver's. It MUST NOT do so when more than one value type declares a method of that name and no expected type otherwise fixes the receiver, since the choice would then depend on declaration order; a literal receiver consequently requires explicit context in that case.

8. Statements and Structured Control Flow

block ::= "{" statement* "}"
statement ::= local-decl | assignment ";" | expression ";"
| control-expression ";"? | if-statement | for-statement
| switch-statement | try-statement
| branch-statement | return-statement | throw-statement
local-decl ::= ("let" | "var") binding ":" binding-type "=" expression ";"
binding ::= identifier | "(" identifier ("," identifier)+ ","? ")"
binding-type ::= type | "(" type-list ")"
place ::= identifier | postfix "->" identifier | postfix "[" expression "]"
assignment ::= place "=" expression
if-statement ::= branch-hint? "if" "(" condition ")" block
("else" (if-statement | block))?
branch-hint ::= "@" ("likely" | "unlikely")
control-expression ::= ("block" | "loop") identifier? control-signature? block
control-signature ::= "(" control-parameter-list? ")" ("->" result-type)?
control-parameter-list ::= control-parameter ("," control-parameter)* ","?
control-parameter ::= identifier ":" type "=" expression
for-statement ::= "for" identifier "in" expression ".." expression
(".." expression)? block
switch-statement ::= "switch" "(" expression ")" "{" switch-item* "}"
switch-item ::= switch-case | switch-default
| comptime-for-switch | comptime-if-switch
switch-case ::= "case" switch-pattern ("," switch-pattern)* ":" block
switch-pattern ::= expression | reference-type
switch-default ::= "default" ":" block
branch-statement ::= "break" identifier? branch-values? ";"
| "continue" identifier? branch-values? ";"
| "br" identifier branch-values? ";"
branch-values ::= "(" expression-list? ")"
return-statement ::= "return" expression? ";"
throw-statement ::= "throw" identifier ("(" expression-list? ")")? ";"
try-statement ::= "try" block catch-clause+
catch-clause ::= "catch" catch-tag? exception-binding? block
catch-tag ::= identifier catch-bindings?
catch-bindings ::= "(" (identifier ("," identifier)* ","?)? ")"
exception-binding ::= "as" identifier
expression-list ::= expression ("," expression)* ","?
condition ::= expression

An if-condition is an ordinary expression (section 7), including its is null/is reference-type forms — those already yield i32, like any other condition. What is special to the if-condition position specifically is narrowing, not the is syntax itself: only when an is test is the entire condition of an if does a true match narrow the tested local's type within the true arm (section 9). The same is test used anywhere else in an expression — as a let initializer, a call argument, a ternary branch, and so on — evaluates to the identical i32, but narrows nothing, since narrowing depends on which lexical branch is taken and only the if-condition position has branches to narrow into.

block and loop are control expressions. Their signature has zero or more parameters and zero or more results; an omitted signature is equivalent to () -> (). The initializer for each parameter is evaluated left to right in the enclosing scope, then the parameter names are bound within the body. When a block or loop begins a statement, it is parsed as a standalone control expression and its final semicolon is optional; in every other expression context, the surrounding construct supplies the terminator.

let answer: i32 = block done() -> i32 {
  br done(42);
};

let retries: i32 = loop retry(attempt: i32 = 0) -> i32 {
  if (attempt == 3) {
    break retry(attempt);
  }
  continue retry(attempt + 1);
};

A block label targets the end of its block. br label(values); to a block MUST provide values matching that block's result type. A loop label targets the beginning of its loop. br label(values); to a loop MUST provide values matching that loop's parameter types.

Labels are lexical and share a namespace distinct from locals and declarations. An inner label may shadow an outer label; a duplicate label at the same lexical depth is an error. br requires an explicit in-scope label. continue targets the nearest loop or for, or an explicitly named loop; it is invalid for a block. Its values MUST match the loop parameter types. break targets the nearest loop or for, or an explicitly named loop. Its values MUST match the loop result type. For a for, both forms require zero values. The compiler lowers a loop with results to a WebAssembly result block enclosing a WebAssembly loop with the declared parameter types.

For example, the done control expression above lowers equivalently to:

(local.set $answer
(block $done (result i32)
(br $done (i32.const 42))))

A control expression with a non-empty result type MUST be used in a context that consumes that result, such as a local initializer or a return. A single-result control expression may also be used as a call argument. Reaching the end of a control body is valid only when its result type is (). A non-empty result therefore requires a branch to the appropriate block or loop-exit target, a return, or an unreachable on every reachable completion path.

A for i in a..b evaluates a and b once, binds an i32 induction variable, begins at a, and ends before b. Both endpoints MUST be i32. The range uses signed i32.lt_s: if a >= b, it executes zero times. The induction binding is immutable, and the compiler increments an internal value with i32.add after each completed body and after a continue.

for i in a..b..s adds an explicit step. Unlike a and b, s MUST be compile-time-known and MUST fit i32, evaluated as in section 13 — its sign selects the comparison that ends the loop, so a positive s runs while i < b and a negative one while i > b. A step of 0 is an error. Omitting the step means 1.

The loop also ends when the next induction value would fall outside i32. Without that rule a large-magnitude step would wrap and re-satisfy the loop condition, so a terminating program and a non-terminating one would differ only by the width of the induction variable. An implementation MUST NOT emit this check for a step of 1 or -1, where overflow is unreachable.

A for is a unit loop target: unlabelled break and continue may target it, but neither accepts values.

A switch is either an integer switch or a type switch. The two case forms MUST NOT be mixed in one switch.

An integer switch selects at most one arm by comparing its scrutinee against the values named by each case. Its scrutinee MUST yield exactly one i32; an implementation MUST NOT insert a conversion to reach that type (section 14).

Every case value MUST be compile-time-known and MUST fit i32: an integer literal, a param, an enclosing comptime for's loop variable, or a unary/binary combination of those, evaluated as in section 13. A value that is not compile-time-known, or that no arm can name twice, is an error: the same value MUST NOT appear in more than one case, whether in one arm's value list or across arms.

A switch MUST have exactly one default arm, and it MUST be the last arm. Requiring it keeps visible in the source what the lowering requires anyway (a br_table has a mandatory default target), rather than having an implementation synthesize fall-through semantics for an unnamed value.

There is no fall-through: exactly one arm's block executes, and control then continues after the switch. A break or continue inside an arm targets the nearest enclosing loop or for, exactly as it would outside the switch -- a switch is not a branch target, and MUST NOT be nameable by break, continue, or br. switch is a statement; there is no expression-form switch.

The scrutinee MUST be evaluated exactly once, including when dispatch lowers to repeated comparisons or casts (section 14's evaluation-order requirement).

A type switch has a reference-typed scrutinee and one reference-type pattern per case, for example case &Dog: or case ?Animal:. Cases are tested in source order. A pattern whose complete value set was already accepted by an earlier pattern is an error. A pattern that is statically disjoint from the scrutinee type is unreachable and SHOULD produce a warning. A type arm cannot use a comma-separated pattern list because the arm would have no single type to which its scrutinee could be narrowed.

When the scrutinee is a bare immutable local or parameter, that binding is narrowed to the case's reference type inside the selected arm, under the same rules as an if type test in section 9. Other scrutinee expressions still dispatch by type but introduce no binding. reedc lowers a type switch to an ordered sequence of br_on_cast instructions over one cached scrutinee.

An implementation MAY choose any dispatch lowering that preserves the above. For an integer switch, reedc selects between a br_table over scrutinee - min and an i32.eq comparison chain, by a value-density heuristic that is deliberately not fixed by this specification, since it affects only output size. Where every arm including default diverges, the implementation MUST still satisfy section 14's validation requirement for the enclosing body -- WebAssembly treats the point after the emitted dispatch as reachable regardless, so an explicit unreachable terminator is required there.

A switch body MAY also contain comptime for/comptime if (section 13), which generate arms; expansion replaces them before the rules above are checked, so a default supplied by a comptime if satisfies the exactly-one requirement.

An if condition MUST yield exactly one i32. The result of an expression-form if is not part of the initial language; if is a statement.

A non-unit function MUST not reach the closing brace normally. Every reachable completion path MUST execute a return with exactly the declared result sequence, branch to an enclosing non-local target, or be unreachable -- either by an explicit unreachable (section 7) or by a throw. A unit function may reach its closing brace, which lowers to no result.

9. Type Tests and Flow Narrowing

value is &HeapType
value is ?HeapType
value is null

These is forms (section 7) are ordinary i32-valued expressions, usable anywhere an expression is valid — let ok: i32 = value is null;, a call argument, a ternary branch, and so on. The left operand MUST have a reference type. A non-compile-time-false heap-type test MUST lower to the appropriate WebAssembly reference test or cast sequence.

Flow narrowing is exclusive to the if-condition position. It applies only when an is test is the entire condition of an if (section 8) and its left operand is a simple immutable local name. In the true arm, that local is narrowed to the tested non-null or nullable reference type. For value is null, it is narrowed to the nullable bottom-reference type. The false arm retains the original type; the initial language does not model complement types. An is test used anywhere else — as a plain expression, or as an if-condition whose left operand is not a simple immutable local — is still evaluated exactly once and yields the identical i32, but narrows no source binding's type. To retain a narrowed value for an arbitrary expression or mutable local, bind it to a let local before the test and narrow on that.

For value is &T, null never matches and a non-null runtime value must be a subtype of T. For value is ?T, a matching runtime value or null matches. value is null is valid for every reference type. A test of a non-null reference against null, or of reference families with no common subtype, is a compile-time false condition; a narrowing if-condition's true arm is unreachable in that case, and the general expression form instead evaluates to a constant 0 (its operand is still evaluated exactly once, for side effects).

9.1 Lowering Examples

This subsection covers the narrowing if-condition lowering specifically. The general expression form of is (used outside a narrowing if-condition) never needs a branch target to carry a narrowed value to, so it MUST instead lower directly to a plain ref.test (for is &T/is ?T) or ref.is_null (for is null), with no br_on_cast/br_on_null involved.

Compile-time-false tests lower directly to an i32.const 0 condition; their true arm is unreachable and MUST NOT require br_on_cast or br_on_null. Every other is &T or is ?T test uses br_on_cast; every other is null test uses br_on_null.

WebAssembly locals have fixed types. Consequently, narrowing a source local does not change the type of its emitted WebAssembly local. When the narrowed value is consumed by an operation requiring the narrower type, the compiler MUST emit a value with that type. For an is test, it MUST use br_on_cast, whose branch target receives the narrowed reference on the stack. It MAY materialize that stack value in a typed branch-local when the value must be used more than once. For an is null test, it MUST use br_on_null.

For example, this source function tests a nullable top reference and consumes the resulting non-null i31 reference:

func i31_payload(value: ?any) -> i32 {
  if (value is &i31) {
    return wasm.i31.get_u(value);
  }

  return 0;
}

It MUST lower equivalently to the following WAT. Local names in generated WAT are illustrative and are not part of Reed's ABI.

(func $i31_payload (param $value (ref null any)) (result i32)
(local $value.i31 (ref i31))
(block $end
(block $matched (result (ref i31))
(br_on_cast $matched (ref null any) (ref i31) (local.get $value))
;; On a failed cast, the original reference remains on the stack.
(drop)
(br $end))
(local.set $value.i31)
(return (i31.get_u (local.get $value.i31))))
(i32.const 0))

On a successful cast, br_on_cast branches to $matched and supplies a statically non-null (ref i31) as that block's result. The failed-cast path falls through with the original (ref null any) value, which the compiler discards before branching to $end. The compiler MUST NOT emit a separate ref.test plus ref.cast sequence solely to implement an is test.

A struct-subtype test follows the same pattern:

func integer_value(value: ?WisteriaHeap) -> i64 {
  if (value is &WisteriaHeapInteger) {
    return value->value;
  }

  return 0;
}
(func $integer_value (param $value (ref null $WisteriaHeap)) (result i64)
(local $value.integer (ref $WisteriaHeapInteger))
(block $end
(block $matched (result (ref $WisteriaHeapInteger))
(br_on_cast $matched
(ref null $WisteriaHeap) (ref $WisteriaHeapInteger)
(local.get $value))
(drop)
(br $end))
(local.set $value.integer)
(return
(struct.get $WisteriaHeapInteger 1
(local.get $value.integer))))
(i64.const 0))

In this example field index 1 is the derived struct's value field: index 0 is the inherited next_vararg field. This demonstrates the parent-first field flattening required by section 6.

Null tests lower to br_on_null rather than ref.is_null:

func is_null(value: ?any) -> i32 {
  if (value is null) {
    return 1;
  }

  return 0;
}
(func $is_null (param $value (ref null any)) (result i32)
(block $end
(block $matched
(br_on_null $matched (local.get $value))
;; The failed null check leaves a non-null reference on the stack.
(drop)
(br $end))
(return (i32.const 1)))
(i32.const 0))

On success, br_on_null transfers directly to $matched; on failure, it falls through with a non-null reference that is discarded before branching to $end. Since a null reference has no payload, br_on_null does not pass a value to its target. If the source true arm uses the narrowed value, the compiler MUST synthesize an appropriate ref.null value. The source type checker still treats value as the nullable bottom-reference type within the true arm.

10. Tables, Memories, Globals, and Tags

A table declaration or import has type ?FunctionType, where FunctionType is a named function type. Its optional initializer is an active element segment at offset zero. Each entry is either null or &function_name; a function reference MUST exactly match the table's function type. The initializer count MUST NOT exceed the declared initial size. Remaining entries are initialized to null.

Table indexing uses the same syntax as arrays. Callbacks[index] evaluates its receiver and index once, requires an i32 index, returns ?Callback, and lowers to table.get. Callbacks[index] = &handler requires a matching ?Callback value and lowers to table.set. #Callbacks evaluates to i32 and lowers to table.size. Indexed table access has WebAssembly's native out-of-bounds trap behavior. A nullable table entry MUST be narrowed or otherwise made non-null before it can be called.

Callbacks.grow(init, delta) -> i32 grows a table by delta elements, each new slot initialized to initnull or &function_name, the same table-entry value grammar as the declaration's own initializer, above — and lowers to table.grow. It returns the table's previous size, or -1 if growth failed. delta MUST be i32 and init MUST match the table's declared ?FunctionType.

The #-for-length operator (section 7), until now defined only for a declared array or table, extends to a declared memory: #Mem -> i32 evaluates to the memory's current size in pages and lowers to memory.size. Mem.grow(delta) -> i32 grows the memory by delta pages and lowers to memory.grow, returning the memory's previous size in pages, or -1 if growth failed. delta MUST be i32.

Mem.fill(offset, value, len); and Table.fill(offset, value, len); are statements of arity zero that fill a region with a repeated value and lower to memory.fill/table.fill respectively. offset and len MUST be i32 for both; value MUST be i32 for a memory (the fill byte, taken from its low 8 bits) and MUST match the table's declared ?FunctionType for a table. DestMem.copy(SrcMem, dest_offset, src_offset, len); and DestTable.copy(SrcTable, dest_offset, src_offset, len); are likewise arity-zero statements: they copy len elements starting at src_offset in the named source memory or table into the receiver starting at dest_offset, and lower to memory.copy/table.copy. dest_offset, src_offset, and len MUST be i32.

The first argument names a second declared memory or table of the same kind as the receiver. It is a declaration argument: an argument position that names a declaration rather than producing a value. It MUST be a bare identifier, it is resolved at compile time in the memory, table, data, or elem namespace as appropriate, and it MUST NOT be evaluated. An implementation MUST resolve it before ordinary expression checking: section 3 requires a memory, table, data, or elem name to be PascalCase, as a global is, so a declaration argument that reached value position would resolve as a global and the operation would read that global's value in its place.

Each of .grow(...), .fill(...), and .copy(...), on both a memory and a table, evaluates its value arguments left to right, exactly once; the receiver name and any declaration argument are resolved at compile time and are never themselves evaluated.

An implementation MAY additionally accept the earlier immediate spelling, DestMem.copy<SrcMem>(dest_offset, src_offset, len), which has identical meaning. It is deprecated and MAY be removed.

Mem.init(SomeData, dest_offset, src_offset, len); and Table.init(Handlers, dest_offset, src_offset, len); are likewise arity-zero statements: they copy len elements starting at src_offset in the named passive data or elem segment into the receiver memory or table starting at dest_offset, and lower to memory.init/table.init. dest_offset, src_offset, and len MUST be i32. The first argument names a declared data segment (for Mem.init) or elem segment (for Table.init), and is a declaration argument subject to every rule stated for .copy(...) above. .init(...) evaluates its three value arguments left to right, exactly once, the same rule as .grow(...), .fill(...), and .copy(...) above. For Table.init(Handlers, ...), Handlers's declared element type MUST be assignable to the receiver table's declared element type — the same element-type-compatibility requirement DestTable.copy(SrcTable, ...) already imposes between two tables, above.

SomeData.drop(); and Handlers.drop(); are arity-zero statements taking no arguments, valid only on a declared data or elem segment name respectively, that permanently mark the named passive segment dropped and lower to data.drop/elem.drop. A segment MAY be dropped whether or not it has already been consumed by .init<...>(...) — dropping only prevents further use, it does not require the segment to still hold data.

Memory limits and table limits are measured in WebAssembly pages and elements, respectively. If a maximum is supplied, it MUST be greater than or equal to the initial size.

The initializer of a global is a source constant expression. The permitted forms are a contextually typed numeric literal, null in a nullable-reference context, a read of an imported immutable global, and a typed wasm operation whose instruction schema is marked a constant expression. All other source expressions, including calls, allocation, local reads, and mutable global reads, are rejected in a global initializer.

A global initializer's constant-eligible wasm operations include the numeric const family, ref.null, ref.func, ref.i31, an imported immutable global's global.get, and the wasm GC allocation opcodes struct.new, struct.new_default, array.new, array.new_default, and array.new_fixed -- each recursing into its own arguments, which must themselves be constant. The dedicated new Name {...}/new Name[count]{} construction syntax is equally valid in a global initializer, lowering through the same rules. i32.add/i32.sub/i32.mul and i64.add/i64.sub/i64.mul (extended-const arithmetic) are also constant-eligible when both operands are, recursing the same way. 0 as &i31 (section 7) is constant-eligible too, since it lowers to the same ref.i31 instruction as wasm.ref.i31(0); no other as conversion lowers to a constant-eligible opcode today. A param's bare name is likewise constant-eligible, resolving the same way it does in an ordinary expression (section 13).

tag Name(types...); declares an exception tag and lowers to a WAT (tag $Name (param types...)) module entry. throw Name(arguments...); requires arguments matching the tag's parameter types and lowers to throw, the same "lowers to X" convention as every other statement in this section.

Exceptions are a checked function effect. A throws (Tag, ...) clause lists the finite set of tags that may escape a function, imported function, or named function type. throws is contextual, not reserved. Every listed name MUST resolve to a tag and MUST occur at most once. Omitting the clause means the empty set; throws () is equivalent but omission is preferred. Declaring a tag that no path currently throws is permitted.

A direct throw Tag(...) is valid only when a lexically enclosing try handles Tag or the current function declares Tag in its throws set. Otherwise it is a type error. A thrown exception unwinds to the first matching handler. If it escapes a function whose signature declares it, it may continue through callers; if it escapes the module, the embedding host is responsible for it.

A call whose callee has a non-empty throw set is valid without additional syntax only when enclosing try handlers catch every tag that can escape that call. Otherwise the call MUST carry a postfix ?, immediately after its call postfix, to explicitly propagate the remaining tags: read()?, receiver.next()?, or callback()?. Every propagated tag MUST be declared by the current function. The ? does not bypass enclosing handlers: tags caught locally are removed first, and only the remainder propagates. A ? that would propagate no tag is a type error. To avoid ambiguity with the ternary operator, a ternary whose condition is a call MAY be parenthesized as (f()) ? a : b; formatting MUST preserve the distinction.

Function references and named function types include their throws sets in their static type. In this version params, results, and throw sets MUST match exactly. An imported function's declared set is a host contract: an embedding that throws a different tag violates that contract.

10.1 Catching Exceptions

try-statement ::= "try" block catch-clause+
catch-clause ::= "catch" catch-tag? exception-binding? block
catch-tag ::= identifier catch-bindings?
catch-bindings ::= "(" (identifier ("," identifier)* ","?)? ")"
exception-binding ::= "as" identifier

A try statement runs its block with the given handlers installed. try and catch are contextual, not reserved: a program MAY still use those words as identifiers.

A try MUST have at least one handler. A catch that names a declared tag binds that tag's payload; the number of bindings MUST equal the tag's declared parameter count, and each binding's type is the corresponding declared parameter type.

A catch that names no tag is a catch-all: it matches any exception and binds no payload. There is no separate keyword for this form — naming no tag is what makes a handler universal, mirroring how section 10's throw distinguishes raising a tag from rethrowing by the operand's form rather than by a second keyword. An implementation MUST resolve the two forms by whether a tag name follows catch, which is unambiguous: only { or as may otherwise appear there.

Each binding MUST use snake_case (section 3) and is scoped to its own handler block.

Handlers are matched in source order. Consequently a handler that no exception can reach is an error, not dead code: an implementation MUST report a type error for a handler following a catch-all, and for a second catch naming a tag an earlier catch in the same try already names.

An optional as name on either clause additionally binds the caught exception itself, of type &exn (section 4.2). An exn MUST NOT be stored, compared, tested, cast, or passed as a value; the sole operation on it is the rethrow form below. This restriction exists because exn is its own type family: no cast or test relates it to any other reference type.

throw name; where name is snake_case rethrows the exception bound by an enclosing catch ... as name, preserving its original tag and payload. It MUST NOT take arguments. A throw whose name is PascalCase is the tag-throwing form of section 10 instead; the two are distinguished by the naming convention of section 3, so no additional keyword is reserved for a rethrow.

The exception reference also carries a compile-time throw set. A tagged catch Tag(...) as name binds {Tag}. A catch as name binds the set of checked tags from the protected body that reach that catch-all after earlier handlers are removed. Rethrowing applies the same checked-effect rule as a fresh throw: outer handlers may catch some tags, and every remaining tag MUST appear in the current function's throws clause. Handler bodies are outside the protected region, so an exception thrown or rethrown by one handler cannot be caught by a sibling handler in the same try.

Control flow out of a protected block is unrestricted: a break, continue, br, or return inside a try body targets exactly what it would target outside it. A try MUST NOT itself be a branch target, and MUST NOT be labelled.

A try statement produces no value. A try whose body must yield a result assigns to a var declared outside it, as an if statement does.

This lowers to try_table, with one enclosing block per handler as its branch target, and throw_ref for a rethrow. A clause lowers to WebAssembly's catch, catch_ref, catch_all, or catch_all_ref according to whether it names a tag and whether an as binding is present.

10.2 Shared and 64-Bit Memories

memory-type ::= ("i64")? memory-limits ("shared")?

A memory declared i64 uses 64-bit addresses: the address operand of every operation on that memory has type i64 rather than i32. The index type is a property of each memory, not of the module, and a module MAY declare both 32-bit and 64-bit memories. An implementation MUST reject an address operand whose type does not match its memory's index type. A 32-bit memory's size MUST NOT exceed 65536 pages; a 64-bit memory's MUST NOT exceed 2^48 pages.

A memory declared shared may be accessed concurrently by multiple agents, and is the prerequisite for the atomic operations of section 12.2. A shared memory MUST declare a maximum size; an implementation MUST report a diagnostic for one that does not, since an unbounded shared memory is not representable.

Both words are contextual and MAY still be used as identifiers. The two are independent and MAY be combined. An imported memory declares the same type, which MUST match what the host supplies.

11. Exports

An export declaration exports the item of its explicitly stated kind. Functions, tables, memories, globals, and tags are exportable. Types are not exportable in the initial language. A source item MAY be exported more than once under distinct strings, but a module MUST NOT contain duplicate WebAssembly export names.

12. Low-Level WebAssembly Operations

Every enabled WebAssembly instruction is available through the wasm escape hatch only when its schema is present in the selected target's instruction catalog. The compiler invocation or project manifest MUST identify an immutable catalog ID and the exact WebAssembly core/proposal revisions from which its schemas are derived. The compiler MUST record that ID in its build output. Changing the catalog ID selects a different language target. An opcode, immediate form, or feature missing from the catalog is a feature error; the compiler MUST NOT guess an instruction's stack effect or immediates. The canonical form is:

wasm.opcode(arguments...)

For example:

wasm.i32.add(left, right)
wasm.table.get<WisteriaVariadics>(index)
wasm.struct.get<WisteriaHeapInteger, value>(reference)

The exact immediate-argument grammar is:

wasm-operation ::= "wasm" "." opcode ("<" immediate-list ">")?
"(" expression-list? ")"
immediate-list ::= immediate ("," immediate)* ","?
immediate ::= identifier | integer-literal | float-literal | type
| immediate-key "=" immediate | "[" immediate-list? "]"
immediate-key ::= identifier
opcode ::= identifier ("." identifier)*

opcode is the dot-separated WebAssembly instruction name. Each instruction schema defines its immediates, operand types, result types, and target-feature requirement, including label arity, memory arguments, and constant-expression eligibility. A catalog MUST include the complete source-syntax schema for every opcode it advertises and a revision-pinned mapping to its WebAssembly binary opcode. The catalog is normative for the selected target; the compiler MUST type-check an operation against it and MUST reject unknown operations or operations unavailable in the selected target.

This form is an escape hatch, not a textual WAT splice: it remains name-resolved and type-checked and cannot bypass module validation.

12.1 Lane-Wise (SIMD) Operations

The catalog MUST include the fixed-width SIMD and relaxed SIMD instruction sets. These have no dedicated source syntax; every one of them is reached through the escape-hatch form above, and each produces or consumes the v128 value type of section 4.1.

A lane-index immediate MUST be an integer literal that is less than the operand shape's lane count (16 for i8x16, 8 for i16x8, 4 for i32x4/f32x4, 2 for i64x2/f64x2). For v128.load<width>_lane and v128.store<width>_lane the lane count is determined by the access width rather than by a shape prefix, and the lane immediate MUST follow the memory immediates. An out-of-range lane index is a type error.

v128.const takes exactly sixteen byte immediates, each in 0..=255. i8x16.shuffle takes exactly sixteen lane immediates, each in 0..=31, since each selects one byte from the 32-byte concatenation of its two operands. Both MAY be written as a flat immediate list or as a single bracketed list immediate.

A SIMD memory operation takes the same memory-immediate group as a scalar one (section 10's offset=/align= form). Its natural alignment is its access width in bytes: 16 for v128.load/v128.store, 8 for the widening NxM loads, and the named width for a <width>_lane/<width>_splat/<width>_zero access. An align= immediate exceeding that is a type error, as for a scalar access.

12.2 Atomic Operations

The catalog MUST include the atomic memory operations of the threads proposal. Like the lane-wise operations of section 12.1, these have no dedicated source syntax and are reached only through the escape-hatch form.

Every atomic operation that addresses memory MUST name a memory declared shared (section 10.2); an implementation MUST report a type error otherwise, at the operation rather than at the memory declaration. atomic.fence is the sole exception: it takes no memory immediate, orders only the executing agent's own accesses, and is therefore valid in a module with no memory at all.

An atomic access MUST be naturally aligned, which follows from the existing align= rule of section 10 applied to the access width named in the opcode.

A read-modify-write operation produces the value the location held before the operation. A compare-and-exchange writes its replacement only when the location's current value equals the expected value, and produces the previous value either way. memory.atomic.wait32/ wait64 take an address, a value to compare against, and an i64 relative timeout in nanoseconds, and produce an i32 status; memory.atomic.notify takes an address and a waiter count and produces the number of agents actually woken.

13. Compile-Time Parameters and Control Flow

A param declaration binds a name to a compile-time-known value of one of four types:

param-decl ::= "param" identifier ":" param-type "=" param-literal ";"
param-type ::= "i32" | "i64" | "bool" | "string"
param-literal ::= ("-")? integer-literal | "true" | "false" | string-literal

bool and string are comptime-only: neither is a runtime value type (section 4), and a param of either type MUST NOT be spliced into a runtime expression (the $name form below). An i32/i64 param MAY be spliced into a runtime expression, where it round-trips as an ordinary integer literal of that width. A param's default MUST fit the declared type's range; an out-of-range or malformed default is a syntax error at the declaration site.

An implementation MUST accept a repeatable --define NAME=VALUE compiler invocation option that overrides a param's default for that compilation only, without editing the source. VALUE is parsed according to the named param's declared type: decimal text for i32/i64 (out of range is an expansion error), true/false for bool (anything else is an expansion error), and the literal text itself for string (always accepted). Naming a param that does not exist, or supplying more than one --define for the same name, is implementation-defined. --define's error text MUST identify both the offending value and the reason it was rejected (not a valid integer; not true/false).

comptime for and comptime if reuse the ordinary for-statement and if-statement grammars (section 8) verbatim, each prefixed with the contextual keyword comptime, at both declaration position (module scope) and statement position (function-body scope):

comptime-for-decl ::= "comptime" "for" identifier "in" expression ".." expression
"{" declaration* "}"
comptime-if-decl ::= "comptime" "if" "(" expression ")" "{" declaration* "}"
("else" (comptime-if-decl | "{" declaration* "}"))?
const-stmt ::= "const" identifier ":" param-type "=" expression ";"
comptime-for-stmt ::= "comptime" "for" identifier "in" expression ".." expression
"{" statement* "}"
comptime-if-stmt ::= "comptime" "if" "(" expression ")" "{" statement* "}"
("else" (comptime-if-stmt | "{" statement* "}"))?

Unlike an ordinary for/if, every operand of a comptime for/comptime if — its range bounds and its condition — MUST be compile-time-known: a param, an enclosing comptime for's own loop variable, an integer or boolean literal, or a unary/binary combination of those. A local, a global, a function call, or any other runtime-only operand there is an expansion error, as is a range bound that is not an integer or a condition that is not a boolean. inline for's range follows the same signed, exclusive-end convention as for (section 8).

An implementation MUST expand every comptime for/comptime if by literally substituting its loop variable's or condition's value into a fresh copy of its body for each taken iteration/branch, then re-parsing the result as ordinary declarations or statements (as its position requires) and splicing them in place of the original construct. By the time resolution begins, a successfully expanded module contains no param, comptime for, or inline if construct — resolution and every later phase operate on ordinary declarations and statements only. This expansion is a distinct pass between parsing and resolution (section 1's phase list); consequently, and unlike every other declaration kind in this specification, a param's declaration order relative to any construct that reads its value MUST be respected: a param, and any name introduced inside a taken comptime for/comptime if branch, is compile-time-known only from that point forward in a single top-to-bottom pass over the module (and, within a function body, over its statements).

Three token-level forms operate inside a comptime for/comptime if body and nowhere else:

  • $name splices a compile-time-known i32/i64 value as an integer literal token (with a leading - for a negative value). It is an expansion error on a bool/string value.
  • [<...>] pastes the textual form of a sequence of identifier fragments and $name splices (of any of the four param types) into a single new identifier token. The pasted text MUST be a valid identifier; an implementation MUST reject any other result as an expansion error.
  • str!(name) (also str!($name)) stringizes a compile-time-known value of any of the four param types into a string-literal token, using its plain textual form (true/false for bool, the literal text for string, and decimal for i32/i64).

Independently of the three splice forms above, a param's bare name is also a valid Ident in any ordinary expression, anywhere in the module -- not only inside a comptime for/comptime if body. It resolves exactly like a read of an imported immutable global (section 10), evaluating to a literal of the param's declared type and (possibly --define-overridden) value. An i32/i64/bool param is valid in this position; a string param is not, since string has no runtime value representation (matching the existing restriction on splicing a string param via $name). A param name MUST NOT collide with a global name, since resolving a bare PascalCase Ident must be unambiguous between the two.

An implementation MUST bound both the nesting depth of comptime for/inline if expansion and the total number of comptime for iterations executed across a single module, and MUST report an expansion error rather than expanding indefinitely once either bound is exceeded. This specification does not fix the exact bound values; an implementation MUST document the bounds it enforces. (reedc uses a nesting depth of 128 and a total-iteration budget of 100,000 across the whole module.)

13.1 Compile-Time Functions

A comptime func binds a name to a template of declarations parameterized by compile-time arguments. Instantiating it splices those declarations into the module, so a generic data structure is written once and specialized per element type:

comptime-func-decl ::= "comptime" "func" identifier
"(" comptime-param-list? ")" "{" declaration* "}"
comptime-param-list ::= comptime-param ("," comptime-param)* ","?
comptime-param ::= identifier ":" comptime-param-kind
comptime-param-kind ::= "type" | "ident" | "i32" | "i64" | "bool" | "string"
comptime-instantiation ::= "comptime" identifier "(" comptime-arg-list? ")" ";"
comptime-arg-list ::= comptime-arg ("," comptime-arg)* ","?
comptime-arg ::= type | expression | string-literal

A comptime func's name MUST be snake_case (section 3), since it is invoked like a call and expands to code, and MUST NOT collide with a macro name. Two parameters of one comptime func MUST NOT share a name. Like a macro, and unlike every other declaration kind, a comptime func MUST be defined before it is instantiated: expansion is a single forward pass.

An instantiation is valid at declaration position only. There is no expression-position form, since the body is a template of declarations.

An implementation MUST check each argument against its parameter's kind at the instantiation, and MUST report a mismatch against the parameter that rejected it, naming both the parameter and the kind it expected:

  • type: the argument MUST parse as a complete type (section 4) and is substituted verbatim, without parenthesization.
  • ident: the argument MUST be exactly one identifier token.
  • i32, i64, bool, string: the argument MUST be a compile-time-known value of that type, evaluated by the same rules a comptime if condition is (above). An i32 argument MUST be range-checked at that width, matching --define and a param's in-source default.

Argument arity MUST match the declared parameter count exactly. This is the principal difference from a macro, whose matcher cannot constrain arity (section 14.1): a comptime func's arguments are separated at top-level commas by the grammar above, so the count is known from the syntax alone.

Within the body, a type/ident parameter is referenced through the token forms of section 13 ($name, [<...>], str!(...)); a value parameter is additionally readable by its bare name in a nested comptime for/comptime if, exactly as a param is. A body MAY contain comptime for, comptime if, a macro invocation, or another instantiation.

A comptime func establishes a closed compile-time scope: the instantiating context's params and enclosing comptime for loop variables are NOT visible inside the body, so what a body expands to depends only on its arguments. (A macro template, by contrast, does see the enclosing environment; section 14.2.)

Two instantiations of the same comptime func with equal arguments MUST produce the declarations once. Because a compilation unit is merged into a single module (section 5.1), a library that instantiates a generator on a program's behalf and a program that instantiates the same one would otherwise be a duplicate-declaration error neither party could fix. Equality is by argument value, so spellings differing only in whitespace are the same instantiation. Two instantiations with different arguments that declare the same name remain an ordinary duplicate-declaration error.

An implementation MUST reject a comptime func that instantiates itself, directly or through any chain of other comptime funcs, and MUST bound the total number of instantiations performed across a single module, reporting an expansion error rather than expanding indefinitely. This specification does not fix the bound; an implementation MUST document what it enforces. (reedc enforces 1,000 instantiations per module.)

The declarations an instantiation produces are ordinary declarations in every later phase: they occupy the single flat namespace, obey the naming rules of section 3, and are exported, referenced, and emitted exactly as written-out declarations are. An implementation SHOULD NOT, however, report an unused-declaration diagnostic for one, nor emit one that nothing reaches: a generator typically declares more than any one instantiation's user needs, and that user cannot edit the generator to silence it.

Declarative, pattern-matching macro definitions — a name bound to a token template, invoked at a call site rather than expanded structurally like comptime for/comptime if — are specified separately in Macros. The two mechanisms compose: a macro template MAY contain comptime for/comptime if, and a comptime for body MAY contain a macro invocation. Where a macro matches raw token shapes, Compile-Time Functions take kinded arguments checked at the instantiation, which is what a generic data structure needs.

13.2 Compile-Time Constants

A const declaration binds a name to a compile-time-known value, exactly as a param does, and differs from one in precisely two respects: its value is an expression rather than a literal, and it MUST NOT be overridden by --define.

const-decl ::= "const" identifier ":" param-type "=" expression ";"

A const is valid at declaration position (module scope) and at statement position inside a function body, like comptime for and comptime if. A statement-position const is compile-time-known from its declaration to the end of the enclosing block, and emits nothing: it introduces no local.

The initializer is evaluated by the same rules a comptime if condition is (section 13): its operands MUST be compile-time-known — a param, another const, an enum member (section 5.3), an enclosing comptime for loop variable, or an integer, boolean, or string literal. A runtime operand there is an expansion error. The result MUST match the declared param-type, and an i32 const MUST be range-checked at that width exactly as a param's default is.

A compile-time expression admits the operators of section 7 over those operands: arithmetic (+, -, *, /, %), comparison, the logical operators (&&, ||, !), the bitwise operators (&, |, ^, ~), the shifts (<<, >>, >>>), and the conditional ? :. + additionally concatenates two string values, and ==/!= compare them; no other operator applies to a string.

Compile-time arithmetic is exact rather than wrapping: an implementation MUST report an expansion error on overflow, on division or remainder by zero, and on a shift count outside 0..63. This differs from the runtime operators, which wrap at their type's width — a compile-time value has no width until it is checked against its declared type, which is where a range error is reported.

For the same reason, >>> on a negative operand MUST be an expansion error: a logical right shift is defined by the operand's width, and a compile-time integer has none, so the expression has no single meaning. >> and a non-negative >>> are unambiguous and MUST be accepted.

A conditional evaluates only the branch it selects, so an operand that would error in the branch not taken is not an error.

A const is compile-time-known from its declaration forward in the same single top-to-bottom pass a param is, so a const whose initializer reads another const MUST follow it. Its name MUST be PascalCase and MUST NOT collide with a param, a global, or an enum type name, for the same reason a param's MUST NOT: resolving a bare PascalCase identifier has to be unambiguous.

Everywhere a param's bare name is valid, a const's is too and means the same thing: an ordinary expression, a global initializer, a switch case value, a comptime if condition, a comptime for bound, and the $name/[<...>]/ str!(...) splice forms of section 13. An i32/i64/bool const is valid as a runtime value; a string const is not, since string has no runtime representation.

const supplements param rather than replacing it: a param states a knob a build may turn, and a const states a derived or intrinsic value a build may not.

14. Macros

A macro binds a name to one or more arms, each pairing a token pattern with a token template:

macro-definition ::= "macro" Ident "{" macro-arm+ "}"
macro-arm ::= "(" macro-pattern? ")" "=>" "{" template "}" ";"?
macro-pattern ::= ( macro-capture | macro-repetition | token )+
macro-capture ::= "$" Ident ":" fragment-specifier
macro-repetition ::= "$" "(" macro-pattern ")" separator? ( "*" | "+" )
separator ::= "," | ";"
fragment-specifier ::= "expr" | "ident" | "literal" | "tt" | "ty"
macro-invocation ::= Ident "!" "(" token* ")"

An invocation is valid in two positions: as a declaration at module scope (name!(...);, requiring the terminating ;) and as an expression.

A macro's name MUST be snake_case, per section 3, since a macro expands to code and is invoked like a call. The name str is reserved: str!(...) is built-in compile-time stringization (section 13), not a macro, so an implementation MUST reject a macro str definition. Two macro definitions MUST NOT share a name.

Unlike every other declaration kind, a macro MUST be defined before it is used: expansion is a single forward pass, so an invocation textually preceding its definition is an expansion error rather than a forward reference.

14.1 Matching

Arms are tried in source order, and the first arm whose pattern matches is selected. A pattern's non-capture tokens MUST match the corresponding argument tokens exactly. Each fragment specifier consumes:

  • ident: exactly one identifier.
  • literal: an optional leading -, then one integer, floating-point, string, true, or false literal. An implementation MUST NOT form a signed value from the magnitude while matching; the sign is retained as a token, so a literal whose magnitude is 2^63 (i.e. i64::MIN) is matched without error.
  • tt: one token, or one whole balanced delimiter group if the token opens (, [, or {.
  • ty: an optional &/? prefix, then one name or one balanced parenthesized group. This is a shape-based match; the resulting tokens are re-validated as a type after substitution, so an ill-formed ty capture becomes an ordinary syntax error at the splice site.
  • expr: every token up to the next non-capture token the pattern expects, at delimiter depth zero. If no such token follows the capture, it consumes the remainder of the argument list, including any top-level ,.

Every capture MUST consume at least one token; a zero-width match is an error, not a successful match. Two captures MUST NOT be adjacent in a pattern with no intervening token, since an expr scan would then have no terminator; an implementation MUST reject such a pattern when the definition is processed, rather than at an invocation.

A consequence of the expr rule above: argument arity is constrained only by a pattern's own separator tokens, never by the matcher. A one-capture pattern ($x:expr) matches m!(1, 2), binding 1 , 2 as a single fragment. Combined with the parenthesization rule in 14.2, that fragment then splices as a tuple (1, 2) rather than as two independent arguments — so a pattern intended to take two arguments MUST spell the separator (($x:expr, $y:expr)); it cannot rely on the matcher to reject a mis-arity call.

If no arm matches, an implementation MUST report an expansion error, and SHOULD report why each arm failed rather than only the last.

14.1.1 Repetition

$( inner )sep* matches zero or more occurrences of inner; $( inner )sep+ matches one or more. When a separator is given, it MUST appear between consecutive occurrences and MUST NOT follow the last.

Matching is greedy: an implementation MUST continue matching occurrences while inner continues to match. In particular, with no separator it MUST NOT stop after the first occurrence — doing so silently truncates the match rather than reporting anything.

inner MUST contain at least one capture, and an occurrence of inner MUST consume at least one token. Both are errors, and the second is what keeps the greedy rule above from becoming an unbounded loop: a repetition whose body can match zero tokens would otherwise never advance. An implementation MUST report rather than loop.

A repetition binds every capture name inside it to a list of that name's per-occurrence bindings. Zero occurrences MUST still bind those names, to empty lists, so that a template's corresponding group substitutes to nothing rather than failing on an unbound name.

14.2 Substitution

Within a template, $name splices the tokens captured by that name. $name resolves against the selected arm's captures first and the enclosing compile-time environment (section 13's params and comptime for variables) second, so a template may reference both. [<...>] paste and str!(...) likewise accept a capture, provided it consumed exactly one token whose text can form the required identifier or string.

A spliced expr fragment of more than one token MUST be parenthesized. Substitution is token-level, so splicing bare tokens would silently re-associate the result: given macro square { ($x:expr) => { $x * $x }; }, the invocation square!(2 + 3) MUST evaluate as (2 + 3) * (2 + 3) (25), not as 2 + 3 * 2 + 3 (11). No other fragment specifier is parenthesized: ty and tt are not expressions, and ident/literal are already atomic.

A template's $( body )sep* group iterates a repetition's bindings, substituting body once per occurrence and inserting sep between consecutive results. The number of iterations is determined by the repeated metavariables body actually mentions:

  • A group mentioning no repeated metavariable is an error, since the iteration count would be undefined.
  • If body mentions two repeated metavariables bound to different numbers of occurrences, that is an error, and the diagnostic SHOULD name both.
  • A capture bound by a repetition MUST NOT be used outside a $(...) group, in a [<...>] paste, or in str!(...): none of those has a single value to use.

A macro invocation appearing inside a template is copied through verbatim and expanded on a later pass, so a macro MAY expand to another invocation.

An implementation MUST bound both the nesting depth of macro expansion and the total number of expansions performed across a single module, and MUST report an expansion error rather than expanding indefinitely once either bound is exceeded. This specification does not fix the bound values; an implementation MUST document what it enforces. (reedc uses a nesting depth of 64 and a total budget of 10,000 expansions per module.)

14.3 Hygiene and provenance

Macros are not hygienic. A name introduced by a template is an ordinary name subject to ordinary resolution, so it can collide with a name at the invocation site. This is a deliberate simplification and MAY change in a future revision.

An implementation MUST attribute a diagnostic arising from template text to the invocation, since the template's own position may be in code the user did not write. A diagnostic arising from a captured argument SHOULD be attributed to that argument's own position, which is text the user did write.

15. Lowering and Validation

The compiler MUST lower a valid program to a WebAssembly module that validates against the selected feature set. It MUST preserve:

  • Scalar types and explicit reference nullability.
  • Function parameter and result order.
  • Struct field order, mutability, inheritance, and subtyping.
  • Explicit import module/field strings and export strings.
  • Structured control-flow semantics.
  • Lexical source evaluation order, including named struct initializers.
  • The exact trap behavior of the selected WebAssembly operation.

The compiler MUST NOT introduce implicit numeric conversions, nullable casts, allocations, calls, or observable reads/writes. It MAY introduce temporary locals, blocks, casts guarded by the specified branch-on-cast instructions, and value reordering only when those changes preserve the preceding guarantees. Every generated instruction MUST be attributable to a source construct or to a required structural lowering described in this specification.

An implementation MUST emit readable WebAssembly Text (WAT). The WAT output MUST include comments identifying the original Reed declaration, statement, or expression responsible for each emitted instruction sequence. Comments for compiler-generated structural instructions MUST identify the source construct that required them. The WAT comments are explanatory only and MUST NOT alter the generated module's semantics.

An implementation MAY additionally emit a WebAssembly binary (.wasm). It MAY provide optimizations behind an explicit flag. Optimized output MUST preserve the observable behavior and trap behavior specified by this document; without that flag, the WAT output MUST retain a direct, source-attributable lowering.

An implementation MAY provide a separate --tail-calls output flag. With that flag, a return whose operand is a direct call with the function's exact result type MAY lower to return_call; one whose operand is an indirect call MAY lower to return_call_ref. Without that flag, both MUST lower as an ordinary call followed by return. The source program is valid in either mode. A compiler MUST NOT diagnose a source feature error merely because tail calls are disabled or unavailable in its default target.

An implementation SHOULD validate emitted binaries with wasm-tools validate and execute runtime fixtures in a conforming WebAssembly engine.

An implementation MAY provide a source formatter. A formatter MUST be meaning-preserving: formatted source MUST lex to the same token sequence as its input, and MUST therefore compile to an identical module. A formatter MUST preserve every comment, and MUST NOT alter the text of a comment or of any literal. It MUST NOT format a source file that has a lexical or syntax error, since the meaning of such a file is not yet determined. Formatting is otherwise unconstrained by this document — layout is not part of the language, and two conforming formatters MAY disagree about it.

16. Open Extensions

The following require future normative sections before implementation:

  • Function-type subtyping.
  • Hygiene for macro definitions (avoiding accidental capture between a macro's own introduced names and its call site's); see section 14.3.
  • Statement-position macro invocation. Section 14 admits declaration and expression position only.