Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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>: either Some(value) or None.
  • An operation that might fail is a Result<T, E>: either Ok(value) or Err(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 Option and Result. ! dispatches through Try; both lift forms — ?. and the bare ? — go through the Lift marker (std::operators). Your own container can opt in and lift the same way, calling its own map and and_then (traits). A continuation that itself produces the container flattens instead of nesting, so find(key)?.shelf() on an Option-returning method stays a single Option.

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
		},
	}
}