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.
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.
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.map(|current| current is Some(let _task));
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. Iterating with for _ in …
skips the binding.
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.