Tutorial: Control Flow and Error Handling
This tutorial covers loops, conditional expressions, optional values (Perhaps<T>), and error propagation (Result<T, E>). These are the tools you will reach for whenever a program needs to make decisions, repeat work, or handle failure.
If, while, for-in, and loop
if/else, while, and for-in work the way they do in most languages, with two notable extras. if doubles as an expression — both branches must agree on a type — and allows a braceless body for a single expression:
fun clamp(value: i64, lo: i64, hi: i64) -> i64 {
if (value < lo) lo else if (value > hi) hi else value
}
fun main() {
println(clamp(15, 5, 10)); // 10
var sum = 0;
for (let i in 1..=10) { // ..= is inclusive; .. is exclusive
sum += i;
}
println("1 + 2 + … + 10 = ${sum}"); // 55
}
loop runs indefinitely; break expr exits it and produces a value, which is what makes loop useful for a search that needs to bail out early with a result:
fun first_over(threshold: i64, values: i64[]) -> Perhaps<i64> {
var i: u64 = 0;
loop {
if ((i as i64) >= values.len()) {
break Perhaps::None;
}
if (values[i] > threshold) {
break Perhaps::Some { value: values[i] };
}
i += 1;
}
}
fun main() {
let result = first_over(6, [3, 1, 7, 2, 9, 4]);
match result {
Perhaps::Some { value } => println("first over 6: ${value}"), // 7
Perhaps::None => println("none found"),
}
}
Perhaps<T>: optional values
Perhaps<T> represents a value that may or may not be present. It has two variants:
Perhaps::Some { value: T }— holds a valuePerhaps::None— holds nothing
Use it whenever a function can legitimately return nothing (a search that finds no result, a field that is not set):
fun find(haystack: String[], needle: String) -> Perhaps<i64> {
var i = 0;
for (let s in haystack) {
if (s == needle) {
return Perhaps::Some { value: i };
}
i += 1;
}
return Perhaps::None;
}
fun main() {
let fruits = ["apple", "banana", "cherry"];
match find(fruits, "banana") {
Perhaps::Some { value: idx } => println("found at index ${idx}"), // 1
Perhaps::None => println("not found"),
}
match find(fruits, "mango") {
Perhaps::Some { value: idx } => println("found at index ${idx}"),
Perhaps::None => println("not found"), // not found
}
}
.yolo() unwraps a Perhaps::Some and panics on Perhaps::None — use it only when you are certain the value is present:
fun main() {
let result = Perhaps::Some { value: 42 };
let n = result.yolo(); // panics if None
println(n); // 42
}
.yolo() Is For Proven Cases
If absence is a normal outcome, keep the match. yolo() is the “this must exist” escape hatch, not the default style.
Result<T, E>: recoverable errors
Result<T, E> represents either a successful value or an error:
Result::Ok { value: T }— successResult::Err { error: E }— failure
Define your error type and return Result from any function that can fail:
struct ParseError {
message: String,
}
fun parse_positive(s: String) -> Result<i64, ParseError> {
if (s == "") {
return Result::Err { error: ParseError { message: "input is empty" } };
}
// In a real program, you'd parse the string here.
// For this example, accept only "42".
if (s == "42") {
return Result::Ok { value: 42 };
}
return Result::Err { error: ParseError { message: "not a valid positive integer: ${s}" } };
}
fun main() {
let inputs = ["42", "", "hello", "42"];
for (let input in inputs) {
match parse_positive(input) {
Result::Ok { value } => println("ok: ${value}"),
Result::Err { error } => println("error: ${error.message}"),
}
}
}
Output:
ok: 42
error: input is empty
error: not a valid positive integer: hello
ok: 42
The ? operator
Writing match on every result is verbose. ? propagates an error automatically: if the result is Err, it returns the error from the current function immediately; if it is Ok, it unwraps the value and continues.
struct ParseError {
message: String,
}
fun parse_positive(s: String) -> Result<i64, ParseError> {
if (s == "") {
return Result::Err { error: ParseError { message: "empty input" } };
}
if (s == "42") { return Result::Ok { value: 42 }; }
if (s == "7") { return Result::Ok { value: 7 }; }
return Result::Err { error: ParseError { message: "unrecognised: ${s}" } };
}
fun double_parse(s: String) -> Result<i64, ParseError> {
let n = parse_positive(s)?; // returns Err immediately if parse fails
return Result::Ok { value: n * 2 };
}
fun main() {
match double_parse("42") {
Result::Ok { value } => println("doubled: ${value}"), // 84
Result::Err { error } => println("failed: ${error.message}"),
}
match double_parse("nope") {
Result::Ok { value } => println("doubled: ${value}"),
Result::Err { error } => println("failed: ${error.message}"), // unrecognised: nope
}
}
? can only be used inside a function whose return type is Result<_, E>. If the error types differ, the inner error type must implement From<InnerError> for the outer error type — see the next section.
Reach For ? After The Result Shape Is Stable
Start with an explicit match if the flow is still confusing. Once the success/error path is clear, replace the boilerplate with ?.
Error coercion with From
When a function calls multiple fallible helpers that return different error types, ? can unify them automatically via the From aspect from the Aspects tutorial. Define extend AppError: From<SomeOtherError> once, and every ? on a Result<_, SomeOtherError> inside a function returning Result<_, AppError> converts through it for free.
struct IoError { msg: String }
struct ParseError { msg: String }
struct AppError { msg: String }
extend AppError: From<IoError> {
fun from(value: IoError) -> AppError {
AppError { msg: "io: ${value.msg}" }
}
}
extend AppError: From<ParseError> {
fun from(value: ParseError) -> AppError {
AppError { msg: "parse: ${value.msg}" }
}
}
fun read_data(path: String) -> Result<String, IoError> {
if (path == "data.txt") { Result::Ok { value: "42" } }
else { Result::Err { error: IoError { msg: "file not found: ${path}" } } }
}
fun parse_number(s: String) -> Result<i64, ParseError> {
if (s == "42") { Result::Ok { value: 42 } }
else { Result::Err { error: ParseError { msg: "not a number: ${s}" } } }
}
// Both ? calls coerce their error type to AppError via From
fun load_and_parse(path: String) -> Result<i64, AppError> {
let raw = read_data(path)?;
let n = parse_number(raw)?;
Result::Ok { value: n }
}
fun main() {
match load_and_parse("data.txt") {
Result::Ok { value } => println("loaded: ${value}"), // loaded: 42
Result::Err { error } => println("failed: ${error.msg}"),
}
match load_and_parse("missing.txt") {
Result::Ok { value } => println("loaded: ${value}"),
Result::Err { error } => println("failed: ${error.msg}"), // failed: io: file not found: missing.txt
}
}
The ? on read_data(path)? automatically calls AppError::from(io_error) because IoError and AppError differ, and extend AppError: From<IoError> exists. No explicit conversion needed.
Match with guards
A match arm can have a guard — a condition that must also be true for the arm to fire:
fun classify(n: i64) -> String {
match n {
0 => "zero",
n if n < 0 => "negative",
n if n % 2 == 0 => "positive even",
_ => "positive odd",
}
}
fun main() {
for (let n in [-3, 0, 4, 7]) {
println("${n}: ${classify(n)}");
}
}
Output:
-3: negative
0: zero
4: positive even
7: positive odd
match also destructures tuples directly, binding or ignoring each element by position:
fun describe(point: (i64, i64)) -> String {
match point {
(0, 0) => "origin",
(x, 0) => "on the x-axis at ${x}",
(0, y) => "on the y-axis at ${y}",
(x, y) => "at (${x}, ${y})",
}
}
A complete example: input validation pipeline
The following program chains several fallible steps and collects results into a summary:
struct ValidationError { field: String, reason: String }
fun validate_name(name: String) -> Result<String, ValidationError> {
if (name.len() == 0) {
return Result::Err { error: ValidationError {
field: "name",
reason: "must not be empty",
}};
}
if (name.len() > 32) {
return Result::Err { error: ValidationError {
field: "name",
reason: "must be 32 characters or fewer",
}};
}
Result::Ok { value: name }
}
fun validate_age(raw: String) -> Result<i64, ValidationError> {
// Simplified: only accept a handful of values for this example.
match raw {
"17" => Result::Err { error: ValidationError {
field: "age",
reason: "must be 18 or older",
}},
"25" => Result::Ok { value: 25 },
"30" => Result::Ok { value: 30 },
_ => Result::Err { error: ValidationError {
field: "age",
reason: "unrecognised value",
}},
}
}
struct User {
name: String,
age: i64,
}
fun validate_user(name: String, age_str: String) -> Result<User, ValidationError> {
let valid_name = validate_name(name)?;
let valid_age = validate_age(age_str)?;
Result::Ok { value: User { name: valid_name, age: valid_age } }
}
fun main() {
let attempts = [
("Ada", "25"),
("", "30"),
("Alan", "17"),
("Ada", "30"),
];
for (let attempt in attempts) {
let name = attempt.0;
let age = attempt.1;
match validate_user(name, age) {
Result::Ok { value: user } =>
println("ok: ${user.name}, age ${user.age}"),
Result::Err { error: e } =>
println("invalid ${e.field}: ${e.reason}"),
}
}
}
Output:
ok: Ada, age 25
invalid name: must not be empty
invalid age: must be 18 or older
ok: Ada, age 30
What you learned
if/elseworks as a statement and as an expression.whilerepeats while a condition holds;for-initerates over arrays and ranges.loop { break value; }produces a value when the right moment is found.Perhaps<T>represents an optional value; always handle bothSomeandNone.Result<T, E>represents a recoverable error;?propagates errors automatically.From<E>lets?coerce between error types without explicit conversions.matchguards (if cond) add extra conditions to individual arms; tuples destructure by position.
Next: Modules — splitting a program across files, imports, and visibility.