Compile-time parameters and control flow
Everything on this page runs before name resolution, not at runtime. A param binds a
compile-time-known value; comptime for/comptime if copy and specialize a chunk of source
text once per iteration or branch, at compile time, using that value; [<...>] and
str!(...) build new identifiers and strings out of it. None of these constructs, nor
any trace of them, survives into the module the rest of the compiler sees — by the time
name resolution runs, the expanded output looks exactly like a module you could have
written by hand.
This is a distinct pipeline stage, expand, that runs between the parser and the
resolver: lexer -> parser -> expand -> resolve -> typeck -> lower -> ir. An error here
is reported as an expansion diagnostic, in its own phase between syntax and
resolution (see Diagnostics).
param: a compile-time-known value with a default
param N: i32 = 4;
param Debug: bool = false;
param Label: string = "v1";
A param has one of four types — i32, i64, bool, or string. None of these is a
new runtime value type. string is comptime-only and can never appear in a runtime
expression or a global initializer — it only ever exists as text, consumed by
str!(...)/[<...>] (below). An i32/i64/bool param reaches runtime two separate
ways: spliced in as an ordinary literal token via $name during expansion (below), or —
independently of that — its bare name read directly, like an ordinary identifier, in any
runtime expression or a global initializer (see Reading a param's bare
name). A param's default must fit its declared type's
range at the declaration site.
Override a default from the command line without touching the source, with a repeatable
--define:
$ reedc build app.reed --define N=6 --define Debug=true
--define's value is parsed according to the named param's type: decimal text for
i32/i64, true/false for bool, or the literal text itself for string. A
malformed value is an expansion error (--define value 'six' is not a valid integer,
--define value 'yes' is not 'true' or 'false') that still names the param's source
declaration.
A param is consumed entirely during expansion when it's used through $name/
[<...>]/str!(...) splicing — there is no runtime global backing that mechanism, and
nothing to look up in a later pass for it. This also means, unlike every other
declaration in this language, a param's position in the file matters for splicing:
expand processes a module top-to-bottom in one pass, so a param must appear before
any splice that reads it.
Reading a param's bare name
Separately from splicing, an i32/i64/bool param's bare name is also a valid
Ident in any ordinary expression, and in a global initializer — not only inside an
comptime for/comptime if body:
param Limit: i32 = 100;
func check() -> i32 {
if (Limit != 100) { return 1; }
return 0;
}
export func check as "check";
This resolves the same way a read of an imported immutable global does (a later pass
over Env.params, not an expansion-time text substitution), so, unlike splicing, the
param's position in the file does not matter for this form — a function defined
before its param declaration can still read it. A string param's bare name is
still rejected in this position (param 'X' has type 'string', which has no runtime value and cannot be used as a value expression), matching the existing restriction on
splicing a string param via $name. A param name also can't collide with a
global name, since resolving a bare PascalCase identifier must be unambiguous
between the two.
const: a compile-time value that is derived, not configured
A const is a param with the two differences that matter: its value is an
expression rather than a literal, and --define cannot change it.
param Slots: i32 = 4;
const Mask: i32 = Slots - 1;
const Wide: bool = Slots > 2;
func check() -> i32 {
if (Mask != 3) { return 1; }
comptime if (Wide) { return 0; }
return 2;
}
export func check as "check";
Reach for param when a build should be able to turn the knob, and const when the
value follows from one that already can. Above, a build may pick Slots, but Mask is
Slots - 1 in every build, and saying so is what stops the two from drifting apart.
A const takes the same four types as a param (i32, i64, bool, string), is
checked against that type, and is range-checked at i32 width. It is valid everywhere a
param is: an ordinary expression, a global initializer, a switch case value, a
comptime if condition, a comptime for bound, and the $name/[<...>]/str!(...)
splice forms below.
Its initializer goes through the same evaluator a comptime if condition does, so its
operands must be compile-time known — a param, an earlier const, an enum member, a
loop variable, or a literal. Naming a global there is an error, not a deferred one:
global Counter: i32 = 5;
const Derived: i32 = Counter + 1; // error: 'Counter' is not a compile-time-known value
Order matters, unlike a param's bare-name read
Expansion is a single forward pass, so a const must be declared above anything it
reads:
const A: i32 = B; // error: 'B' is not a compile-time-known value here
const B: i32 = 1;
This is the same rule param and macro already carry, and it is a genuine difference
from the bare-name read described above: a function may read a const declared below
it, because that read is resolved by a later pass. Only another compile-time
initializer needs the declaration to come first.
string consts
string is the one type with no runtime representation, so a string const exists only
to be compared, concatenated, and stringized:
const Prefix: string = "reed";
const Full: string = Prefix + "c-1";
const Matches: bool = Full == "reedc-1";
comptime if (Matches) {
@custom("build-id", str!(Full));
}
+ concatenates and ==/!= compare; there is no ordering comparison, since the
language does not define one over strings. Using a string const as a runtime value is
an error, exactly as it is for a string param.
comptime for / comptime if: compile-time control flow
comptime for and comptime if reuse the ordinary for/if grammar verbatim, with an
inline prefix, and work at both module scope (declaration position) and inside a
function body (statement position):
comptime for i in 0..N {
// one copy of this body per value of i, i = 0, 1, ..., N-1
}
comptime if (Debug) {
// this branch only
} else {
// or this one
}
The difference from a plain for/if is what's allowed in the range/condition and what
happens to the body. A plain for's bounds and a plain if's condition are ordinary
runtime expressions, evaluated once, at runtime, in the compiled module. An inline for's bounds and a comptime if's condition must instead be compile-time-known: a
param, an enclosing comptime for's own loop variable, an integer/boolean literal, or a
combination of those. Reading a local, a global, or a function call result there is an
expansion error, not a type error:
// rejected: 'x' is a runtime local, not compile-time-known
func check() -> i32 {
let x: i32 = 1;
comptime for i in 0..x {
return 0;
}
return 1;
}
export func check as "check";
expansion error at 4:24: 'x' is not a compile-time-known value here (expected a 'param' or an enclosing 'comptime for' loop variable)
Given a compile-time-known range/condition, the compiler substitutes the concrete value into a fresh copy of the body for every taken iteration or branch, then parses that copy as ordinary declarations or statements and splices the result in place of the original construct — the loop or branch itself never reaches the compiled module; only its already-specialized bodies do.
$name, [<...>], and str!(...): using the value
Three token-level forms read a compile-time-known value inside a comptime for/inline if body (and nowhere else):
$namesplices ani32/i64value in as an integer literal token (with a leading-if negative). It's an expansion error on abool/stringvalue — those have no runtime literal form to splice as.[<...>]pastes a sequence of plain identifier fragments and$namesplices (of any of the four types) together into a single new identifier token, e.g.[<Counter $i>]withi = 3produces the identifierCounter3. The pasted text must form a valid identifier — pasting a fragment sequence that starts with a digit, for example, is an expansion error.str!(name)(alsostr!($name)) stringizes a compile-time-known value of any of the four types into a string-literal token, using its plain textual form.
Nesting
A comptime for/comptime if may contain another one. Each level binds its own loop
variable, and an inner header may read an outer level's variable by its bare name (0..i,
not 0..$i — $name is body syntax, headers take ordinary compile-time expressions):
param N: i32 = 2;
param M: i32 = 2;
comptime for i in 0..N {
comptime for j in 0..M {
global [<Cell $i X $j>]: i32 = 0;
}
}
func check() -> i32 {
return Cell0X0 + Cell0X1 + Cell1X0 + Cell1X1;
}
export func check as "check";
That expands to the four globals Cell0X0, Cell0X1, Cell1X0, Cell1X1 — one per
combination — and check() returns 0, since each defaults to 0.
Worked example: compile-time select-style dispatch
This program builds N global counters and a select function that dispatches on an
index to the matching one, entirely via comptime for — the loop never appears in the
compiled output, only its N unrolled copies do:
param N: i32 = 4;
comptime for i in 0..N {
global [<Counter $i>]: i32 = 0;
}
func select(index: i32) -> i32 {
comptime for i in 0..N {
if (index == $i) {
return [<Counter $i>];
}
}
unreachable;
}
func check() -> i32 {
return select(2) + select(0) + select(3) + select(1);
}
export func check as "check";
With the default N = 4, this expands to four globals (Counter0..Counter3) and four
ifs inside select, each comparing index against a literal and returning the
matching global. All four counters default to 0, so check() returns 0. Compiling with
reedc build, then validating with wasm-tools, then running with wasmtime run --invoke check confirms this: the module validates and check returns 0, matching the
prose above.
That is O(N) comparisons at runtime. Putting the same comptime for inside a
switch instead generates case arms, which lower to a single
br_table — the arms are still generated at compile time, but the dispatch becomes one
indexed jump rather than a chain:
param N: i32 = 4;
comptime for i in 0..N {
global [<Counter $i>]: i32 = 0;
}
func select(index: i32) -> i32 {
switch (index) {
comptime for i in 0..N {
case $i: { return [<Counter $i>] + $i; }
}
default: { unreachable; }
}
}
func check() -> i32 {
return select(0) + select(1) + select(2) + select(3) - 6;
}
export func check as "check";
Each counter is 0, so select(k) is k, and 0 + 1 + 2 + 3 - 6 is 0.
--define N=6 instead produces six counters (Counter0..Counter5) and six ifs, with
no trace of Counter6 — the same source, specialized to a different compile-time value,
with no source edit required.
Limits
Expanding comptime for/comptime if is bounded on two axes, to turn a runaway expansion
(a self-referential comptime for bound, or simply too large an N) into a diagnostic
instead of an unbounded compile:
- Nesting depth: a
comptime for/comptime ifnested inside another, more than 128 levels deep, is an expansion error (compile-time expansion nested past the maximum depth of 128). - Total iterations: the sum of
comptime foriterations actually executed across the whole module, once past 100,000, is an expansion error ('comptime for' exceeded the maximum of 100000 total iterations across the module) — not a per-loop limit, a whole-module budget.
Both numbers are this compiler's own choice, not a language requirement; another implementation may choose different bounds, but must document whatever it enforces and must report a diagnostic rather than expand indefinitely once its bound is hit.
Editor support
A param is a real symbol to the language server, not just an expansion-time value.
Hover, go-to-definition, completion, the document outline, and rename all work on it, in
every position it can appear:
- its declaration (
param Width: i32 = 3;), - a bare read in an ordinary expression (
return Width + 1;), - a
comptime forrange bound (comptime for i in 0..Width), - and a
$Widthsplice inside a template body.
Hover shows the effective value, i.e. after any --define override, since that is
what the code you are looking at actually compiled with.
Rename is supported for a param, unlike for a macro or a comptime func: every use of
a param survives in the source as the literal identifier, including the $Width splice
form, so the rename reaches all of them. It is refused when the new name is already taken
by a global or a type, rather than silently producing a module with a name collision.
The same applies inside a compile-time construct: a comptime for's loop variable and a
comptime func's own parameters are renameable, splices included. Those two are exempt
from the snake_case rule renaming enforces elsewhere, because the language does not
constrain their case either and T/Name/Count is the conventional spelling. What stays
refused is renaming a generator or a macro itself, for a different reason: by then the
declarations an instantiation produced are indistinguishable from hand-written ones.
A macro arm's captures behave the same way as a generator's parameters, and
for the same reason: the capture survives in the source as a literal $name, so a rename
reaches the pattern and every splice together. What is refused is renaming the macro or
the generator itself, which is the sentence above -- not the compile-time names either
one binds.
Inside a comptime for/comptime if body, everything resolves as it would outside one:
the loop variable at its binding and at every $i splice, and the parameters and locals of
any function the body declares. A comptime for's loop variable works in all three places
one can appear — at module scope, inside a function body, and nested inside a
comptime func.
The same holds inside a comptime func template body, which needs its own
machinery: a template is never compiled as written, so nothing the compiler produced
describes the text you are reading. Its parameters hover as comptime param T: type, at
their declaration and at every $T splice, and a generator is a closed scope in the
editor exactly as it is in the language — one generator's parameters are never offered
inside another's body.
Go-to-definition on a generated declaration lands on whatever produced it: the
instantiation for a comptime func, or the loop body for a comptime for. It cannot land
on the declaration's own name, because there isn't one — make_int_box comes from
[<make_ $prefix>], so that spelling appears nowhere in the source.
A [<...>] paste used as a type name does resolve, though.
array [<$Name Storage>] { mut $T } declares a type, and every later
&[<$Name Storage>] refers to it: within one template the spelling is fixed, so
identical text names the same declaration whatever the arguments turn out to be. Hover
and go-to-definition work from any of those positions. A paste that names no declared
type — a function name, say — stays silent rather than guessing.
Field completion follows from the same reasoning. Typing self-> inside a generator offers
the fields of the pub struct $Name that template declares, because whichever type $Name
becomes, self always points at that struct. Field types are shown as written
(mut &[<$Name Storage>]), since there is no resolved type until an instantiation runs.
Hovering a field name says the same thing, and a . position offers the methods the
template declares on its own struct — both work before anything has instantiated the
generator, which is exactly when you are writing it.
What this doesn't do (yet)
- No hygiene for
macrodefinitions. Macros do exist — a named pattern/template pair invoked at a call site, complementary to the structural expansion on this page, and variadic via$(...),*— but a name a template introduces is an ordinary name, so it can collide with one at the call site (see Open Extensions). - No macro-expansion preview in the editor. A diagnostic inside an expanded
comptime for/comptime ifbody is attributed to the original invocation's source span, not to the specific token that produced it — there is no LSP feature to step through what a particular iteration expanded to.
comptime for/comptime if used to be skipped inside a block/loop written as a value
(a let initializer, a return operand, a call argument) — expansion walked statements
only, so the construct survived to type checking and was reported as unexpanded. That gap
is closed: expansion now walks every expression, so a comptime for works the same way
wherever you write it.
param N: i32 = 3;
func check() -> i32 {
var total: i32 = 0;
let doubled: i32 = block done() -> i32 {
comptime for i in 0..N {
total = total + $i;
}
br done(total * 2);
};
return doubled - 6;
}
export func check as "check";
The comptime for sits inside a block used as a let initializer. It unrolls to three
total = total + <k>; statements for k = 0, 1, 2, so total is 3, doubled is 6,
and check() returns 0.