Skip to main content
v0.12.0

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 value
  • Perhaps::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 } => println("found at index ${value}"), // 1
Perhaps::None => println("not found"),
}

match find(fruits, "mango") {
Perhaps::Some { value } => println("found at index ${value}"),
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
}
caution

.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 } — success
  • Result::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.

tip

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 } =>
println("ok: ${value.name}, age ${value.age}"),
Result::Err { error } =>
println("invalid ${error.field}: ${error.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/else works as a statement and as an expression.
  • while repeats while a condition holds; for-in iterates over arrays and ranges.
  • loop { break value; } produces a value when the right moment is found.
  • Perhaps<T> represents an optional value; always handle both Some and None.
  • Result<T, E> represents a recoverable error; ? propagates errors automatically.
  • From<E> lets ? coerce between error types without explicit conversions.
  • match guards (if cond) add extra conditions to individual arms; tuples destructure by position.

Next: Modules — splitting a program across files, imports, and visibility.