Control flow
Normative rules: spec §3 Grammar and §5.10
!,?.and?.
if / else
if is an expression. Both branches produce a value, and the whole thing
has that value:
fun main() {
let n = 7;
let label = if n % 2 == 0 { "even" } else { "odd" };
print(label);
}
There is no ternary operator because if already is one — and when both
branches are short, then/else spells it without the braces.
then / else
cond then a else b is if cond { a } else { b } written infix. It is sugar:
the compiler reads it as that if, so typing, scoping and errors are the
if’s. As a value it needs both branches, exactly as if does, and a chain
reads left to right as an else if ladder:
fun sign(n: i32): str {
n < 0 then "negative" else n == 0 then "zero" else "positive"
}
fun main() {
let verbose = true;
let label = verbose then "loud" else "quiet";
print(i"{label} {sign(-3)}");
}
Written as a STATEMENT — ended by its ; — each branch is one statement, so
the two branches need not produce the same type, and either may be left out.
cond then S; runs S when cond holds, and cond else S; runs it when it
does not — the guard, which reads best before the work it protects:
fun first_positive(xs: List<i32>): i32 {
for x in xs {
x > 0 then ret x;
}
-1
}
fun main() {
let ready = first_positive([ -1, 4 ]) > 0;
ready else print("nothing to do");
ready then print("ready") else print("waiting");
}
When a guard’s else leaves — ret, jump, a panic — the only way past
it is the condition holding, so what an is in the condition binds stays
bound for the rest of the block:
fun describe(found: Option<i32>): str {
found is Some(let n) else ret "none";
i"got {n}"
}
fun main() {
print(describe(Some(4)));
}
The word binds looser than || and tighter than =: a || b then x else y
tests a || b, and label = ok then "yes" else "no"; assigns the whole form.
A branch is a single statement with no block of its own, so a let there is
refused — bind it first, or write an if. then is a keyword only in this
position; promise.then(f) and let then = 1; are ordinary names.
match
match is the workhorse, a switch that can take values apart and that
the compiler checks for completeness. Arms are pattern => expression.
Payloads bind with let, and _ catches everything else:
import std::option::Option::{ self, Some, None };
fun describe(slot: Option<i32>): str {
match slot {
Some(let value) => i"got {value}",
None => "empty",
}
}
fun main() {
print(describe(Some(3)));
print(describe(None));
}
If you forget a variant, the compiler tells you. That’s most of the
reason enums plus match replace flag fields and null checks.
A payload binds with let or with mut, the same two words that
declare a variable — let immutably, mut mutably:
match slot {
Some(mut items) => {
items.push(9); // `let items` here is "cannot mutate immutable"
slot = Some(items); // the binder is a copy: write it back
}
None => void,
}
A binder is a binding, so it holds a copy of what it matched, like
every other binding (see the memory model). mut
makes that copy writable; it does not reach the value you matched on.
An arm that means to change the subject assigns back through it, as
above. Writing both words (Some(let mut items)) is neither form, and
the compiler says so.
Completeness is judged over the whole pattern, not just its outermost name, so a payload you narrow has to be handled too:
match slot {
Some(0) => "zero", // an i32 payload: no list of numbers covers it
Some(let value) => "some", // this is what covers it
None => "empty",
}
Drop the middle arm and the compiler says “match is not exhaustive:
missing Option::Some(_)” — it names a value that has nowhere to go,
spelled as the arm that would take it.
An arm can carry a guard, an if that has to hold for the arm to
run:
match state {
Status::Ready if budget > 0 => start(),
Status::Ready => wait(),
Status::Done => finish(),
}
A guarded arm proves nothing about completeness, and the compiler says
so: a guard tests the value where the check reasons about the type,
so writing Status::Ready only on a guarded arm still leaves Ready
missing — reported with a note pointing at the guard. The rule that falls
out is worth remembering: the last arm may not be guarded. Give it a
_ => … afterwards, so the value the guard rejects has somewhere to go.
One more edge belongs to backed enums, whose
variants carry a host string or number. An exhaustive match over one is
complete over the variant set, which is a proof about your side of the
boundary and not about the value: a host that answers outside the set
reaches a trap arm and panics naming it
(Align: "middle" is not one of its values) rather than being filed
under whichever variant came last. Add a _ arm, or take the value
through Align::parse, wherever an unrecognized value is an answer you
expect.
When you only need a yes/no answer instead of a full match, is tests a
pattern as a boolean:
let present = entry.derive(|current| current is Some(let _task));
A let inside the pattern captures, and the capture is in scope
wherever the test is known to have passed: the then-branch, and the rest
of the condition after an &&. It is not in scope in the else, after
the if, or in the other arm of a || — each of those is reached
exactly when the test didn’t pass, so there is no payload to name:
if slot is Some(let task) && task.ready { start(task); } // both fine
if slot is Some(let task) { … } else { start(task); } // error: unbound
The condition has to say the test passed, and it says that through
!, &&, || and the is itself. Bury the test in a call argument
and the condition’s answer no longer reports it — ready(slot is Some(let task)) is whatever ready returns — so the capture is out of
scope in both branches:
if ready(slot is Some(let task)) { start(task); } // error: unbound
For the same reason, a test written outside a condition altogether binds
nothing after itself. let ready = slot is Some(let task); gives you a
bool, and a bool does not carry the payload — nothing later in the
block proves the test passed, so reading task there is refused and the
error points you at the shape that works:
let ready = slot is Some(let task); start(task); // error: not a condition
let ready = slot is Some(let task) && task.ready; // fine: `&&`, as always
Negating the test swaps the two branches: !(slot is Some(let task)) is
true where the pattern didn’t match, so the capture is unbound in that
branch and in scope in the else — the branch reached only by a match.
if !(slot is Some(let task)) { start(task); } // error: unbound
if !(slot is Some(let task)) { … } else { start(task); } // fine: it matched
That swap is what makes the guard clause work. Negate the test, leave
the if through a ret or a jump, and the code after it is reachable
only on the path where the pattern matched — so the capture is in scope
there, for the rest of the block:
if !(slot is Some(let task)) { ret; }
start(task); // fine: it matched
It needs all three parts. The then-branch has to actually leave (a branch
that can fall through reaches the continuation on a miss), the if has to
have no else, and the test has to be negated — if slot is Some(let task) { ret; } continues exactly where the pattern didn’t match.
One edge to know: a match can’t sit directly inside a larger operator
expression. Bind it to a local first.
Arms that never produce a value (a ret, a panic, a
jump break/continue) don’t participate in the match’s type. The
other arms decide it:
let value = match slot {
Some(let text) => text, // the match is a str
None => panic("missing"), // this arm diverges; no annotation needed
};
Loops
for covers every loop. It has three forms:
import std::range::Range;
fun main() {
for item in ["a", "b", "c"] {
print(item);
}
for i in Range::new(0, 3) { // 0, 1, 2 — the end is exclusive
print(i);
}
mut count = 0;
for count < 3 { // a while loop: runs while the condition holds
count += 1;
}
print(count);
}
There is also a bare for { … } for an infinite loop. Loop control is
spelled jump break and jump continue. A bare for that nothing
breaks out of never finishes, so the code after it is unreachable and a
function that ends in one owes no return value. Iterating with for _ in …
skips the binding.
The header’s binder is the same one let takes, so an element that is a
tuple can be destructured right there:
fun main() {
for (index, item) in ["a", "b"].iter().enumerate() {
print(i"{index}: {item}");
}
}
Any other pattern in the header — a variant, a literal — is refused by name: bind the element and destructure it in the body.
One more form matters once you care about performance:
for e in &mut list iterates views of the elements so you can mutate
them in place. That’s a memory model topic.
Early return: ret
fun parse(path: str): Route {
if parts.len() == 0 {
ret Route::Home; // early exit
}
Route::NotFound // the final expression is the return value
}
Option and Result
Vilan has no null and no exceptions. Instead:
- A value that might be absent is an
Option<T>: eitherSome(value)orNone. - An operation that might fail is a
Result<T, E>: eitherOk(value)orErr(error).
Both are plain enums with a rich set of helper methods (unwrap_or,
map, is_some, and friends; the
full list). You can always match on them.
Two operators make the common patterns short.
! unwraps, or propagates the failure. Think of it as “give me the
value, and if there isn’t one, return the failure from this function”:
import std::option::Option::{ self, Some, None };
import std::result::Result::{ self, Ok, Err };
fun to_number(text: str): Result<i32, str> {
match text.parse_i32() {
Some(let value) => Ok(value),
None => Err(text),
}
}
fun sum(a: str, b: str): Result<i32, str> {
let left = to_number(a)!; // an Err returns early, carrying the error
let right = to_number(b)!;
Ok(left + right)
}
fun main() {
match sum("2", "40") {
Ok(let total) => print(total),
Err(let bad) => print(i"not a number: {bad}"),
}
}
This is what try/catch becomes: failures travel up through return
types, visibly, and the caller decides what to do.
! returns the failure as-is, so the value’s error type must
already be the function’s: Vilan doesn’t convert it behind your back.
When the
types differ, convert at the value, before the !: .map_err(…) changes
a Result’s error, and .ok_or(err) turns an Option’s None into an
Err you supply.
import std::option::Option::{ self, Some, None };
import std::result::Result::{ self, Ok, Err };
fun lookup(key: str): Option<i32> {
if key == "answer" { Some(42) } else { None }
}
fun read(key: str): Result<i32, str> {
let value = lookup(key).ok_or(i"no entry for {key}")!; // None -> Err
Ok(value)
}
fun main() {
match read("answer") {
Ok(let value) => print(value), // 42
Err(let why) => print(why),
}
match read("missing") {
Ok(let value) => print(value),
Err(let why) => print(why), // no entry for missing
}
}
?. reaches inside the container. It looks like optional chaining
from JS, and on Option it plays the same role, with the compiler
checking it:
import std::option::Option::{ self, Some, None };
struct Book {
title: str,
}
fun find(key: str): Option<Book> {
if key == "hit" {
Some(Book { title = "dune" })
} else {
None
}
}
fun main() {
print((find("hit")?.title).unwrap_or("?")); // dune
print((find("miss")?.title).unwrap_or("?")); // ?
}
find("hit")?.title means: if the option holds a book, take its title
and wrap it back up; if it’s None, stay None. It works on Result
too, passing an Err through untouched.
A bare ? lifts a whole expression. Where ?. continues a member
chain, a ? on its own lifts the rest of the surrounding expression,
operators included:
import std::option::Option::{ self, Some, None };
fun main() {
let price = Some(40);
let tax = Some(2);
print((price? * 2).unwrap_or(-1)); // 80 — lift, multiply, rewrap
print((price? + tax?).unwrap_or(-1)); // 42 — Some only if BOTH are
let missing: Option<i32> = None;
print((price? + missing?).unwrap_or(-1)); // -1
}
With two ?s the region is good only when every receiver is. It
short-circuits left to right, so a receiver right of a None/Err
never evaluates (like &&). On Result, the first Err wins and
passes through unchanged, which also means every Result receiver in
one expression must carry the same error type (convert first with
.map_err(…)). The lift stops at natural boundaries (a call argument,
a struct field, parentheses): describe(status?) is an error rather
than something spooky, and (a? + 1) * 2 seals the lift inside the
parens. A ? in an if condition is rejected too: the condition
would become an Option<bool>, so match on the lifted value
instead.
Going deeper. Both operators are trait-driven, not hard-coded to
OptionandResult.!dispatches throughTry; both lift forms —?.and the bare?— go through theLiftmarker (std::operators). Your own container can opt in and lift the same way, calling its ownmapandand_then(traits). A continuation that itself produces the container flattens instead of nesting, sofind(key)?.shelf()on anOption-returning method stays a singleOption.
Panics and asserts
panic(message) stops the program with a message. Use it for states
that should be impossible, not for expected failures; those are
Results. assert(condition, message) panics when the condition is
false, and it’s how vilan test decides a test failed.
A panic names where it happened, on both backends and in release builds
too: panicked at src/main.vl:12:5: not a number: x. assert, unwrap,
expect, an index out of bounds and List’s remove/insert name the
line that CALLED them, not a line inside std. Your own helpers can do the
same with [track_caller] (Debugging).
A panic never returns, so it fits wherever a value is expected and owes
nothing back: a function whose body ends in one needs no return value,
and neither does one that ends in a for { … } nothing breaks out of.
fun parse_id(text: str): i32 {
match text.parse_i32() {
Some(let id) => id,
None => {
panic("not a number: " + text); // no `0` after it
},
}
}