Syntax cheat sheet
Every construct in the language, on one page. Normative rules are in the specification; this is the lookup table.
Naming is load-bearing
Case is not style — the resolver uses it to pick a namespace.
| Kind | Case | Notes |
|---|---|---|
| function, parameter, local | snake_case | required, enforced |
| global | PascalCase | required, enforced |
| struct, array, function type | PascalCase | shared type namespace |
| labels, data segments, elem segments | own namespace | separate from the above |
A bare snake_case value expression resolves only to a local; a bare
PascalCase one resolves only to a global. A local can therefore never
shadow a global. Wrong case is a resolution error, not a lint.
Types
bool transparent alias of i32; false = 0, true = 1
i32 i64 f32 f64 scalar WebAssembly value types
v128 SIMD vector; no literals, no operators, no `as`
i8 i16 storage only — struct fields / array elements
() no result
(i32, i64) multiple results
&T non-null reference -> (ref $T)
?T nullable reference -> (ref null $T)
Built-in heap types: any eq i31 struct array none func nofunc
extern noextern exn noexn. A struct name alone is never a value type — always &Name
or ?Name. Legacy spellings (anyref) do not exist; write ?any.
Subtyping: none < i31/struct/array < eq < any; nofunc < func;
noextern < extern; noexn < exn. The four families are otherwise
disjoint. An exn comes only from a catch ... as e clause and can only be
rethrown. Named
function types are invariant — they match only themselves.
There are no implicit numeric conversions, ever.
Declarations
struct Name { field: i32, other: mut f64 }
struct Child : Parent { own: i32 }
array Name { mut i32 }
type Callback = func(i32) -> i32 throws (Failure);
type Count = i32; // transparent; emits no WebAssembly type
type MaybeWidget = ?Widget; // aliases a complete type, including nullability
packed struct Rgb : i32 { // bit fields in one integer; i31/i32/i64, no allocation
red: u8, // uN unsigned, iN signed, bool is one bit
opaque: bool, // read c->red, write var/global only, bits as Rgb to convert
}
enum Color { Red, Green = 5, Blue } // C-style: Blue is 6; Color is a transparent i32
enum Signal : i64 { Low = 1 } // i32 by default; read a member as Signal.Low
enum Color4 : u4 { Red, Green, Blue } // a WIDTH: iN/uN. Members range-checked at it.
// Narrow = storage only: Color4 is still i32 as a
// value, i8 as a struct field, 4 bits when packed.
/// Markdown shown in hover/signature help and by `reedc doc`. Allowed on any
/// declaration that introduces a name, not just a `func`.
func name(a: i32, b: &Widget) -> i32 { ... }
func risky() -> i32 throws (Failure) { ... }
func unit() { ... }
func multi() -> (i32, i64) { ... }
global Name: i32 = 0;
global Counter: mut i32 = 0;
memory Heap(1); // min 1 page (64 KiB), no max
memory Heap(1 16); // min 1, max 16
memory Shared(1 16 shared);// shared: atomics allowed; a max is required
memory Big(i64 1); // 64-bit addresses (memory64)
table Callbacks(2 ?Callback) = { &handler, null };
data Greeting = "hi"; // passive
data Header at 0 = "Reed"; // active, requires exactly one memory
elem Handlers: ?Callback = { &handler, null }; // always passive, no 'at' form
tag Failure(i32); // declares a tag
@custom("name", "bytes"); // a WebAssembly custom section
@custom("marker") before code; // ... optionally placed
@inline func small() -> i32 { ... } // calls are substituted; not emitted
@deprecated func old() -> i32 { ... } // each use warns [deprecated-call]
@deprecated("use new_one") func old2() -> i32 { ... } // ... with a migration note
import "env"."double" as func double(i32) -> i32;
import env."double" as func double(i32) -> i32; // module name unquoted, same import
import "env"."risky" as func risky() -> i32 throws (Failure);
import "env"."memory" as memory Heap(1 16);
import "env"."Limit" as global Limit: i32;
import "env"."table" as table T(1 ?Callback);
export func name as "abi_name";
export global Counter as "counter";
export memory Heap as "memory";
export table Callbacks as "table";
start initialize;
Declaration order never matters. Imported functions give parameter types
only (double(i32)), defined functions give names and types. Only func,
global, memory, table, and tag are exportable — types are not. One item may
be exported under several names; two items may not share an export name.
Statements
let x: i32 = 1; // immutable
var y: i32 = 1; // assignable
let (lo, hi): (i32, i32) = pair(); // destructuring, arity must match
y = 2; // var local, or mut global
obj->field = 2; // field must be mut
arr[0] = 2; // element must be mut
if (cond) { } else if (c2) { } else { }
for i in 0..10 { } // signed i32, end exclusive
for i in 0..10..2 { } // explicit step; must be compile-time-known
for i in 10..0..(-2) { } // a negative step counts down
switch (x) { // i32 scrutinee; no fall-through
case 0: { } // case values are compile-time-known
case 1, 2: { } // one arm, two values
default: { } // required, and must be last
}
switch (value) { // reference scrutinee; ordered type tests
case &Dog: { value->field; } // narrows a bare immutable local/parameter
case ?Animal: { }
default: { }
}
return expr; // arity must match the function result
return; // only when result is ()
throw Failure(1); // raise a tag; unwinds if uncaught
throw caught; // rethrow (lowercase name = an `exn` binding)
risky()?; // explicitly propagate unhandled declared tags
try { } catch Failure(code) { } // handlers match in source order
try { } catch Failure(c) as e { } // ... `as e` also binds the exception itself
try { } catch { } // no tag named = matches anything; must come last
@likely if (c) { } // branch hint; @unlikely too. Advisory only.
unreachable; // traps; diverges, so it can end a non-unit body
expr; // arity 0, or arity 1 then dropped
There is no while, no expression-form if, no expression-form switch, no
compound assignment (+=), no block comments. Comments are // only.
break/continue inside a switch arm target the enclosing loop, not the
switch.
Control expressions
block and loop are expressions with a signature. An omitted signature means
() -> ().
let answer: i32 = block done() -> i32 {
br done(42);
};
let n: i32 = loop retry(attempt: i32 = 0) -> i32 {
if (attempt == 3) { break retry(attempt); }
continue retry(attempt + 1);
};
| Form | Targets | Carries |
|---|---|---|
br label(v) | explicit label, required | block: its results. loop: its parameters |
continue label?(v) | nearest loop/for, or a named loop | loop parameter types |
break label?(v) | nearest loop/for, or a named loop | loop result types |
continue is invalid for a block. A for accepts unlabelled break/
continue but never values. Parameter initializers are evaluated left to
right in the enclosing scope, then bound in the body.
Reaching the end of a control body is valid only when its result is ().
A non-unit block/loop needs a branch, return, or trap on every reachable
path — as does a non-unit function.
Type tests and narrowing
if (value is &Circle) { /* value : &Circle here */ }
if (value is ?Circle) { }
if (value is null) { }
is &T— null never matches.is ?T— null matches.is null— valid for any reference type.- The false arm keeps the original type. There are no complement types.
Narrowing applies only when the operand is a bare immutable (let/param)
local name. A var, a field read, a call result, or any compound expression
still evaluates the test but silently does not narrow — with no diagnostic.
Bind to a let first.
Lowering: is &T/is ?T → br_on_cast; is null → br_on_null. A
statically impossible test folds to a constant-false condition instead.
Expressions
new Point { x: 1, y: 2 } // struct: named fields, every field exactly once
new Samples { 1, 2, 3 } // array: positional, may be empty
obj->field // struct read; receiver must be non-null
obj->packed.u // packed i8/i16 read: .s or .u REQUIRED
arr[i] arr[i].s // array read (table read uses the same syntax)
#arr #Callbacks // array.len / table.size
&handler // typed function reference (needs contextual type)
f(a, b) // direct call
callback_ref(a) // indirect call via &FuncType -> call_ref
(a, b) // tuple: return operand or destructuring only
cond ? then_expr : else_expr // ternary; both arms always evaluate (select)
(x = value) // assignment-as-expression; 'var' locals only -> local.tee
value is &T value is null // 'is' as a plain i32 expression, usable anywhere
wasm.i32.add(a, b) // escape hatch
Packed (i8/i16) fields and elements require a .s/.u suffix on read
and reject it on non-packed ones. Writes take an i32 and keep the low bits.
The ternary is right-associative, not short-circuiting (both branches evaluate
before select picks one), and binds looser than ||. An is expression
narrows only when it is the entire condition of an if and its operand is
a bare immutable local or parameter — see Type tests and narrowing;
everywhere else (including here) it still evaluates but never narrows.
Numeric and array methods
Fixed built-in catalog (spec section 7). A method you declare yourself resolves first, and may not take one of these names — see Methods below:
x.clz() x.ctz() x.popcnt() // i32/i64, no args
x.rotl(n) x.rotr(n) x.div_u(n) x.rem_u(n) // i32/i64, one same-type arg
x.lt_u(n) x.le_u(n) x.gt_u(n) x.ge_u(n) // i32/i64, one same-type arg -> i32
x.abs() x.sqrt() x.ceil() x.floor() // f32/f64, no args
x.trunc() x.nearest() // f32/f64, no args
x.min(y) x.max(y) x.copysign(y) // f32/f64, one same-type arg
let (lo, hi): (i64, i64) = x.mul_wide_u(y); // i64 only, TWO results; also _s
dest.copy(dest_offset, src, src_offset, count); // array.copy; dest element mut
arr.fill(offset, value, count); // array.fill; element mut
.copy/.fill are statements only — calling either produces no value.
Methods
A qualified function name declares a method on a type (spec section 7.1). It is an ordinary function with a dotted name, so it is also callable by name, referencable, and exportable. See Methods.
func i32.clamped(self: i32, low: i32, high: i32) -> i32 { ... } // value-type receiver
func Vec2.dot(self: &Vec2, other: &Vec2) -> i32 { ... } // non-null struct receiver
func Bytes.total(self: &Bytes) -> i32 { ... } // non-null array receiver
func i32.clamped(self, low: i32, high: i32) -> i32 { ... } // same, via the shorthand
func Vec2.dot(&self, other: &Vec2) -> i32 { ... } // `&self` for a reference
import "env"."t" as func i32.triple(i32) -> i32; // a method the host supplies
export func i32.clamped as "clamped"; // exportable
table Fns(1 ?Un) = { &i32.triple }; // referencable
v.clamped(0, 20) // method call: v is `self`
i32.clamped(v, 0, 20) // the same call, spelled by name
(21).doubled() // literal receiver: parens required, type comes from the method
pub func Point.sum(...) is visible to other files under its qualified name, so an import
spells it that way: use util.{Point, Point.sum}; (a glob import needs no spelling).
First parameter is the receiver, must be named self (except in an import, which has no
parameter names), and must be exactly the receiver type. It may be written as bare self
(value receiver) or &self (reference receiver) with no annotation; the sigil must match the
receiver's kind, and there is no ?self. Dispatch is static and searches a
struct's parent chain. A nullable receiver has no methods — narrow first. @inline is allowed
on methods and substitutes both .m(...) and T.m(...) calls.
Memory and tables
#Mem // memory.size -> i32 pages
Mem.grow(delta) // memory.grow -> previous size, or -1
Callbacks.grow(init, delta) // table.grow -> previous size, or -1
Mem.fill(offset, value, len); // memory.fill
Table.fill(offset, value, len); // table.fill
DestMem.copy<SrcMem>(dest_offset, src_offset, len); // memory.copy
DestTable.copy<SrcTable>(dest_offset, src_offset, len); // table.copy
Mem.init<SomeData>(dest_offset, src_offset, len); // memory.init
Table.init<Handlers>(dest_offset, src_offset, len); // table.init
SomeData.drop(); Handlers.drop(); // data.drop / elem.drop
Atomics need a shared memory, and are escape-hatch only:
wasm.i32.atomic.load<Shared>(addr) // also .store, and 8/16/32 widths
wasm.i32.atomic.rmw.add<Shared>(addr, v) // returns the PREVIOUS value
wasm.i32.atomic.rmw.cmpxchg<Shared>(a, exp, new) // writes only if it matched
wasm.memory.atomic.wait32<Shared>(a, exp, timeout_ns)
wasm.memory.atomic.notify<Shared>(addr, count)
wasm.atomic.fence() // no memory, so no `shared` needed
An address is i32, or i64 for a memory declared i64.
<Name> immediates (<SrcMem>, <SomeData>, ...) are declaration references
resolved at compile time, never evaluated. .grow/.fill/.copy<...>(...)/
.init<...>(...)/.drop() are all statements except .grow(...), which
returns the previous size.
Conversions (as)
i64 as i32 // wrap_i64, no suffix (also f32 as f64, f64 as f32)
i32 as i64.s // sign-extend, suffix required (also .u for zero-extend)
i32 as f64.u // convert, suffix required (signedness of the int operand)
f64 as i32.s // trunc, suffix required; traps out-of-range or NaN
i32 as &i31 // ref.i31 (box), no suffix
&i31 as i32.s // i31.get_s (unbox); also .u, and ?i31 source
A suffix is required exactly where the (operand, target) pair is
signedness-ambiguous, and rejected everywhere else. as rejects a same-type
cast outright, so two rows have no as spelling: same-width bit
reinterpretation, and in-place sign extension (extend8_s/extend16_s/
extend32_s, i32→i32 or i64→i64) — use
wasm.<opcode> for both.
Operator precedence
Lowest to highest binding:
is (x is T; only valid as the entire expression, see above)
? : (ternary)
||
&&
|
^
&
== !=
< <= > >=
<< >> >>>
+ -
* / %
unary ! - ~ # and &function_name
postfix () ->field [index] .s/.u
| Operator | Operands | Result | Lowering |
|---|---|---|---|
+ - * | matching i32/i64 | same | add/sub/mul, wraps |
/ % | matching i32/i64 | same | signed div_s/rem_s; traps on ÷0 and signed overflow |
+ - * / | matching f32/f64 | same | IEEE 754 |
- (unary) | any numeric | same | 0 - v, or float neg |
~ & | ^ | matching integers | same | bitwise |
<< >> >>> | matching integers | left | shl / shr_s / shr_u |
< <= > >= | matching integers | i32 | signed only |
< <= > >= | matching floats | i32 | IEEE; false for NaN |
== != | matching numerics | i32 | eq/ne; float != true for NaN |
== != | references below ?eq | i32 | ref.eq |
! | i32 | i32 | eqz |
# | non-null array ref | i32 | array.len |
&& || | i32 | i32 | short-circuit via structured if |
Unsigned comparison, unsigned division, and rotation are method-postfix calls,
not operators (see Numeric and array methods);
conversion is as (above). Same-width bit reinterpretation and in-place sign
extension have no operator or method either — use
wasm.<opcode>.
Arity rules
Every expression has a result arity, and it never expands implicitly.
- Arity 1 required by: call arguments, operator operands, field receivers,
array initializers,
ifconditions, struct field initializers. - Arity 0 or 1 allowed as an expression statement (arity 1 gets an explicit
drop). Arity 2+ as a statement is a type error. - Arity 2+ is allowed only as a
returnoperand or the right side of a parenthesized binding of equal arity. unreachableis exempt: it adopts whatever arity and types the context expects, including zero results and multi-result bindings, because control never reaches the point where the values would be needed.
Literals
42 0xFF 0b1010_1010 1_000_000 // decimal / hex / binary; `0X`/`0B` also accepted
0x1_0000_0000 // i32 or i64 only, needs context
1.5 1e10 1.5e-3 // f32 or f64 only, needs context
true false // i32 1 and 0
null // needs a nullable-reference context
"text" // imports, exports, data only
Every numeric literal needs a contextual type. let x: i32 = 1 + 2; is fine;
1 + 2; is not. Negation is the unary operator, not part of the literal.
String escapes: \" \\ \n \r \t \u{...}.
Compile-time parameters and control flow
param N: i32 = 4; // also i64/bool; splices via $name, or read by bare name
param Label: string = "v1"; // comptime-only -- no runtime value; splice/stringize only
const Mask: i32 = N - 1; // like param, but an expression, and --define cannot change it
const Tag: string = Label + "-rc"; // string + concatenates; == / != compare
comptime for i in 0..N { } // module scope (decl) or function body (statement)
comptime if (Debug) { } else { }
[<Counter $i>] // pastes into one new identifier: Counter3, ...
str!(N) // stringizes a param/loop-var value: "4"
$i // splices an i32/i64 comptime value as a literal
--define N=6 overrides a param's default at the command line. Every operand of an
comptime for's range or a comptime if's condition must be compile-time-known (a
param, an enclosing comptime for's own loop variable, or a literal) — a local, a
global, or a call there is an expansion error, reported in its own phase between
syntax and resolution. See Compile-time parameters and control
flow for the full grammar, limits, and a worked example.
Macros
macro square { // snake_case; MUST precede its first use
($x:expr) => { $x * $x }; // arms tried top-to-bottom, first match wins
($x:expr, $y:expr) => { $x * $y };
}
square!(2 + 3) // expression position -> 25, not 11 (auto-parenthesized)
make_pair!(one, two); // declaration position at module scope; ';' required
Fragment specifiers: $x:expr (an expression, up to the pattern's next literal token),
$x:ident (one identifier), $x:literal (one number/string/bool, optional leading -),
$x:ty (a type), $x:tt (one token or one balanced group).
Repetition:
macro make_adders { // `,` or `;` separator, or none at all
($($name:ident = $val:literal),*) => { // `*` zero-or-more, `+` one-or-more
$( func $name(x: i32) -> i32 { return x + $val; } )*
};
}
make_adders!(one = 1, two = 2); // one arm, any number of pairs
A multi-token expr fragment is parenthesized when spliced, so square!(2 + 3) means
(2 + 3) * (2 + 3). Two adjacent captures (($a:expr $b:expr)) are rejected at
definition time. Arity is enforced only by a pattern's own separator tokens, never by the
matcher. A repetition body must contain a capture and must consume at least one token; a
template group must mention a repeated name, and two repeated names of different lengths
cannot be zipped. Macros are not hygienic, and statement-position invocation does not
exist. Depth is capped at 64 expansions, total at 10,000 per module. See
Macros.
Generics (compile-time functions)
comptime func vec_of(T: type, Name: ident, prefix: ident) { // snake_case; MUST precede use
pub array [<$Name Storage>] { mut $T } // body is a declaration template
pub struct $Name { items: mut &[<$Name Storage>] }
pub func [<new_ $prefix>]() -> &$Name { /* ... */ }
pub func $Name.len(self: &$Name) -> i32 { /* ... */ }
}
comptime vec_of(i32, IntVec, int_vec); // declaration position only; ';' required
Parameter kinds: type (a real type, parsed at the call), ident (one identifier),
i32/i64/bool/string (a comptime value, also readable by bare name inside a nested
comptime if/comptime for). Arity is checked, unlike a macro's.
Both a Name and a prefix because a type must be PascalCase and a function snake_case.
Instantiating the same generator twice with equal arguments is a no-op, not a duplicate.
Generated declarations are ordinary declarations, but are never reported as unused and are
emitted only when reached. A generator may not recurse; 1,000 instantiations per module.
See Generics, and std.vec for
the standard library's containers.
Global initializers
These forms are constant expressions:
- A contextually typed numeric literal.
nullin a nullable-reference context.- A read of an imported immutable global, or of a
param's bare name. - A const-eligible
wasmop:i32.consti64.constf32.constf64.constref.nullref.funcref.i31global.get, the GC allocation opsstruct.newstruct.new_defaultarray.newarray.new_defaultarray.new_fixed, and extended-consti32.add/i32.sub/i32.mul/i64.add/i64.sub/i64.mul— each recursing into its own (also constant) arguments. - The dedicated
new Name { ... }/new Name[count]{}allocation syntax, when it lowers to one of the ops in (4). 0 as &i31(lowers toref.i31) — no otherasconversion is constant-eligible.
Calls, local reads, reads of a defined (non-imported) global, and mutable-global reads are all still rejected.
Modules and visibility
use util.{add}; // one name from another Reed file
use util.{add, Point}; // several
use util.*; // every pub name
use util.{add as plus}; // under a different name IN THIS FILE only
use net.http.{get}; // the file net/http.reed
pub use util.{add}; // RE-EXPORT: add joins this file's own pub surface
pub use util.*; // re-export util.reed's whole pub surface
import "env"."log" as func log(i32) -> (); // a WebAssembly HOST import
use std.math.*; // a STANDARD LIBRARY module
use std.list.{IntList}; // one name from one
use imports a Reed file; import imports from a WebAssembly host. A path is
dotted with no quotes and no .reed: the last segment is the file, earlier ones are
directories, and it is relative to the importing file. A path rooted at std names a
standard library module and is not a file at all; std and
core are reserved, so no source file may use those names.
There is no brace-less single-name form — use util.add; is an error, since it cannot be
told apart from a longer path. A path segment may be a keyword (use std.array.*;); an
item may not. An as alias is a synonym in the importing file only: it never renames the
declaration, and it does not shadow a local of the same name.
pub func shared() -> i32 { return 1; } // visible to other Reed files
export func shared as "shared"; // visible to the WebAssembly host
pub and export are independent: pub is compile-time visibility and emits
nothing, export is the only thing that creates a WebAssembly export. pub also
applies to struct, array, type, param, const, enum, macro, and
comptime func, none of which can be exported. Names must be unique across every
file in the unit. See
modules.
Imports are not transitive. A name is visible where it is declared and in files
that import it directly; importing a file gives you nothing that file itself
imported. The name is still in the emitted module — a unit is one flat namespace —
but using it is an error naming the file that declares it. pub import re-exports,
forwarding exactly the names it imports and never widening visibility. Re-exports
compose while every edge is marked. pub on a host import is an error. See
re-exporting.
Formatting
reedc fmt normalizes spacing, indentation, and blank lines; it preserves your
brace layout (one-line bodies stay one-line) and never adds a trailing comma.
reedc fmt --check is the CI form. Editors get the same formatter through
reedc lsp. See tooling.
Where things live
- Standard library — the ten
std:modules. - Limitations — what's missing and what's undertested.
- Opcode catalog — all 199
wasm.*operations. - Diagnostics — every error message and what causes it.
- Specification — the normative document.