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
v128value 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.mulandi64.add/i64.sub/i64.mulas 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:
pubcontrols 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@deprecatedare 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.
@inlineis 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:
- 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.
- Core wraps instructions; standard modules are ordinary Reed. Every core
declaration is
@inlineand wraps a single WebAssembly instruction. A standard library module has no such restriction: it MAY declare types, use recursion, and allocate. - 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 orelementry, 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:
i8for 1..8 bits,i16for 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.MemberNameexpression -- 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 )requiresName's element type to be a value type (numeric or packedi8/i16) andDto name a declared data segment; it lowers toarray.new_data. A reference-typed element is a compile error, sincearray.new_datacannot produce references.new Name [ count ] elem < E > ( offset )requiresName's element type to be a reference type assignable fromE's declared elem type (always a nullable reference toE's function type, per the elem declaration above) andEto name a declared elem segment; it lowers toarray.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/.usuffix 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.
| Operator | Operand types | Result | Lowering and behavior |
|---|---|---|---|
+, -, * | matching i32 or i64 | same type | add, sub, mul; wraps modulo width |
/, % | matching i32 or i64 | same type | signed div_s, rem_s; divide by zero and signed overflow trap |
+, -, *, / | matching f32 or f64 | same type | IEEE 754 add, sub, mul, div |
- | i32, i64, f32, or f64 | same type | integer 0 - value or floating neg |
~, &, |, ^ | matching i32 or i64 | same type | integer bitwise operation |
<<, >>, >>> | matching integer operands | left type | shl, signed shr_s, unsigned shr_u |
<, <=, >, >= | matching integers | i32 | signed comparison |
<, <=, >, >= | matching floats | i32 | IEEE comparison; false for NaN |
==, != | matching numeric types | i32 | eq/ne; float != is true for NaN |
==, != | nullable references below ?eq | i32 | ref.eq / negated ref.eq |
! | i32 | i32 | eqz; returns canonical 0 or 1 |
# | non-null declared array reference | i32 | array.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:
- The part of the name after the
.MUST besnake_case(section 3). The qualifier follows the naming rule its own type already has. - 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.
- 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.
- In a
funcdeclaration, the first parameter MUST be namedself. Afuncimport declares parameter types without names, so this requirement does not apply to one. - 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.
- 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 init — null 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:
$namesplices a compile-time-knowni32/i64value as an integer literal token (with a leading-for a negative value). It is an expansion error on abool/stringvalue.[<...>]pastes the textual form of a sequence of identifier fragments and$namesplices (of any of the fourparamtypes) 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)(alsostr!($name)) stringizes a compile-time-known value of any of the fourparamtypes into a string-literal token, using its plain textual form (true/falseforbool, the literal text forstring, and decimal fori32/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 acomptime ifcondition is (above). Ani32argument MUST be range-checked at that width, matching--defineand aparam'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, orfalseliteral. 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-formedtycapture 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
bodymentions 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 instr!(...): 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
macrodefinitions (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.