Skip to main content

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.

KindCaseNotes
function, parameter, localsnake_caserequired, enforced
globalPascalCaserequired, enforced
struct, array, function typePascalCaseshared type namespace
labels, data segments, elem segmentsown namespaceseparate 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);
};
FormTargetsCarries
br label(v)explicit label, requiredblock: its results. loop: its parameters
continue label?(v)nearest loop/for, or a named looploop parameter types
break label?(v)nearest loop/for, or a named looploop 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 ?Tbr_on_cast; is nullbr_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, i32i32 or i64i64) — 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
OperatorOperandsResultLowering
+ - *matching i32/i64sameadd/sub/mul, wraps
/ %matching i32/i64samesigned div_s/rem_s; traps on ÷0 and signed overflow
+ - * /matching f32/f64sameIEEE 754
- (unary)any numericsame0 - v, or float neg
~ & | ^matching integerssamebitwise
<< >> >>>matching integersleftshl / shr_s / shr_u
< <= > >=matching integersi32signed only
< <= > >=matching floatsi32IEEE; false for NaN
== !=matching numericsi32eq/ne; float != true for NaN
== !=references below ?eqi32ref.eq
!i32i32eqz
#non-null array refi32array.len
&& ||i32i32short-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, if conditions, 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 return operand or the right side of a parenthesized binding of equal arity.
  • unreachable is 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:

  1. A contextually typed numeric literal.
  2. null in a nullable-reference context.
  3. A read of an imported immutable global, or of a param's bare name.
  4. A const-eligible wasm op: i32.const i64.const f32.const f64.const ref.null ref.func ref.i31 global.get, the GC allocation ops struct.new struct.new_default array.new array.new_default array.new_fixed, and extended-const i32.add/i32.sub/i32.mul/ i64.add/i64.sub/i64.mul — each recursing into its own (also constant) arguments.
  5. The dedicated new Name { ... }/new Name[count]{} allocation syntax, when it lowers to one of the ops in (4).
  6. 0 as &i31 (lowers to ref.i31) — no other as conversion 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