Control flow
$if and $or branch on comptime-evaluable conditions. Only the taken arm is compiled; the discarded arms are never resolved, type-checked, or emitted - unlike runtime if / or, which always generates a branch.
The $if / $or form
A $if chain mirrors runtime if / or in shape, but each gate is a comptime condition and only the selected branch reaches the binary. $or with a condition is an else-if; $or with no condition is the comptime else.
$if (cond) {
...
}
$or (cond) {
...
}
$or {
... # comptime else
}
Target gating is the most common use: select the right import or backend per build, and leave the rest out of the binary entirely.
$if ($mach.build.os == $mach.os.linux) {
use full.os.linux;
}
$or ($mach.build.os == $mach.os.windows) {
use full.os.windows;
}
$or {
$error("unsupported OS");
}
Comptime conditions
The gate must be a comptime expression. Common shapes:
$mach.*reads for target and build conditions- comparisons of comptime constants (
pub valdeclarations) - comparisons of a comptime function parameter - see below
A comptime comparison or arithmetic relates the mathematical values of its operands, exactly as the runtime operators do. A constant in the range 2^63 to 2^64 - 1 carries its true unsigned magnitude, so $if (0xFFFFFFFFFFFFFFFF > 0) is taken, and any cross-sign comparison agrees with the runtime form: $if (X < Y) never selects a branch that if (X < Y) would not.
Comptime arithmetic that overflows the value's range is a compile error, not a silent wrap.
When a declaration-scope $if is decided
A $if chain written in declaration scope runs at one of two times, and what its arms contain picks which.
- Some arm declares something. The chain is decided while names are being resolved, because what it decides is which declarations exist and every later stage reads the resulting set. Nothing has a type at that point, so the gate cannot ask a type question: a layout intrinsic, a type predicate, or a
$type_ofcomparison there is rejected, with a message naming the reason. - No arm declares anything. The chain contributes no name and no type whichever arm is taken, so nothing depends on deciding it early. It is decided during type checking instead, where its gate may measure a type (
$size_of,$align_of,$length_of), query one ($is_recordand friends), or compare one.
The question is answered from the syntax, over every arm - $if, every $or, and a trailing $or {} - before any gate is evaluated. One declaring arm anywhere keeps the whole chain at the earlier time. Per-arm answers are not possible: which stage runs the gate would then depend on which arm the gate selects, and the stage that would have to know that is the one being chosen.
A use is a declaration, so a conditional import is always decided while names are resolved. That is what makes the common target-gating form above work.
rec MeshUniforms { model: [16]f32; }
# no arm declares: decided during type checking, so the gate may measure
$if ($size_of(MeshUniforms) != 64) {
$error("MeshUniforms must be 64 bytes");
}
# the second arm declares, so the whole chain is decided while names are
# resolved - and the gate is rejected there
$if ($size_of(MeshUniforms) != 64) {
$error("MeshUniforms must be 64 bytes");
}
$or {
val PADDING: u32 = 0;
}
The one visible consequence is ordering: a $error reached under a chain that declares nothing is reported during type checking, so an unrelated name-resolution error elsewhere in the same module is reported before it rather than after.
A $if inside a function body is always decided during type checking - it selects statements rather than declarations, so the question does not arise and its gate may always ask about a type.
Branching on a comptime parameter
A comptime function parameter ($mode: u8) is a compile-time-known argument, fixed per call site. $if / $or may branch on it: the compiler monomorphizes the body once per distinct comptime-argument value, and each instance compiles only the arm its value selects.
val MODE_DOUBLE: u8 = 0;
val MODE_SQUARE: u8 = 1;
fun apply($mode: u8, n: i64) i64 {
$if (mode == MODE_DOUBLE) {
ret n + n;
}
$or (mode == MODE_SQUARE) {
ret n * n;
}
ret 0;
}
# apply(MODE_DOUBLE, ..) and apply(MODE_SQUARE, ..) emit two distinct bodies,
# each carrying only its selected arm.
Rules
- The argument bound to a
$-parameter must be a compile-time constant at the call site - a literal, apub val, or another comptime parameter. A runtime value is rejected withcomptime argument is not a compile-time constant. Cross-module constants work, whether imported by bare name or as a qualified member (alias.CONST). - Each arm gate must itself be comptime-foldable: its identifiers must all be comptime (parameters or constants). A gate that references a runtime local or parameter is rejected.
- A comptime parameter has no storage, so its address cannot be taken:
?$modeis rejected withcannot take the address of a comptime parameter. - A comptime-parameter function is a template, not a value - it can only be called, never assigned, passed, or compared.
val fp = apply;is rejected withcannot reference a comptime-parameter function as a value. - Comptime parameters carry no runtime cost: they are stripped from the lowered signature and ABI, so only the runtime parameters are passed. They may be mixed freely with runtime parameters in any order.
- An instance is emitted against its declaring module and folds its gates against that module's own comptime constants, so a library can export a comptime-parameter function gated on its own
pub vals. - Combining a comptime parameter with a generic function (
fun f[T]($mode: u8, ...)) is planned - it is reported today with a clear diagnostic.
Because each instance compiles only its taken arm, a comptime parameter can safely gate per-target asm blocks: a register one backend does not recognize lives only in the arm the other backend never compiles.
pub fun load($order: Order, ptr: *i64) i64 {
var result: i64 = 0;
$if ($mach.build.arch == $mach.arch.aarch64) {
$if (order == RELAXED) {
asm aarch64 { ldr {result}, [{ptr}] }
}
$or (order == ACQUIRE) {
asm aarch64 { ldar {result}, [{ptr}] }
}
}
ret result;
}
Discarded branches
An untaken $if branch is absent from the compiled output, but what "not taken" means for name resolution depends on what the gate reads.
$mach.*or constant gates - the untaken arms are entirely absent and the compiler does not even resolve names inside them. This is what makes per-targetasmblocks safe when one arm references registers the other backend has never heard of.- comptime-parameter gates - the one exception. Because arm selection happens per call site at monomorphization, name resolution and type checking run over all arms structurally, and only the selected arm is emitted into each instance. Every arm must therefore be independently resolvable and type-checkable.
$type_oftype gates - not an exception. At monomorphization the operand's concrete type is known, so provably-dead arms are pruned and only the selected arm is type-checked and emitted. Each arm may use its value at its own concrete type with no per-arm cast.
See also
- The comptime channel -
$mach.*reads for target and build conditions - Intrinsics -
$type_ofdispatch and the comptime toolkit - Statements - the runtime
if/orcounterpart - Variadic packs -
$if/$orinside$eachbodies