Skip to main content
v0.13.0

Reference: Control Flow, Options, and Errors

Reference material. Result and ? are introduced in the arc on Parse into fields, fail loudly; this page is the full picture — if/match/loop/while/for, Perhaps<T>, and error propagation.

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 None;
}
if (values[i] > threshold) {
break Some { value = values[i] };
}
i += 1;
}
}

fun main() {
let result := first_over(6, [3, 1, 7, 2, 9, 4]);
match (result) {
Some { value } => println("first over 6: ${value}"), // 7
None => println("none found"),
}
}

Enums

An enum is a fixed set of named alternatives. A variant may carry named fields:

enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
Unit,
}

fun area(s: Shape) -> f64 {
match (s) {
Circle { radius } => 3.14159 * radius * radius,
Rect { w, h } => w * h,
Unit => 1.0,
}
}

fun main() {
println(area(Rect { w = 2.0, h = 3.0 })); // 6
}

match on an enum must cover every variant (or end in _). A bare variant name in a pattern binds its fields (Circle { radius }); a no-field variant is written Unit. The qualified form Shape::Circle { … } is accepted anywhere and is required in an expression when the expected type doesn't pin the enum down.

Perhaps<T> and Result<T, E> (below) are ordinary enums — None / Some { value } and Err { error } / Ok { value } are just their variants.

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 Some { value = i };
}
i += 1;
}
return None;
}

fun main() {
let fruits := ["apple", "banana", "cherry"];

match (find(fruits, "banana")) {
Some { value } => println("found at index ${value}"), // 1
None => println("not found"),
}

match (find(fruits, "mango")) {
Some { value } => println("found at index ${value}"),
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 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 Ok { value = 42 };
}
return 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)) {
Ok { value } => println("ok: ${value}"),
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 Err { error = ParseError { message = "empty input" } };
}
if (s == "42") { return Ok { value = 42 }; }
if (s == "7") { return Ok { value = 7 }; }
return 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 Ok { value = n * 2 };
}

fun main() {
match (double_parse("42")) {
Ok { value } => println("doubled: ${value}"), // 84
Err { error } => println("failed: ${error.message}"),
}
match (double_parse("nope")) {
Ok { value } => println("doubled: ${value}"),
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") { Ok { value = "42" } }
else { Err { error = IoError { msg = "file not found: ${path}" } } }
}

fun parse_number(s: String) -> Result<i64, ParseError> {
if (s == "42") { Ok { value = 42 } }
else { 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)?;
Ok { value = n }
}

fun main() {
match (load_and_parse("data.txt")) {
Ok { value } => println("loaded: ${value}"), // loaded: 42
Err { error } => println("failed: ${error.msg}"),
}
match (load_and_parse("missing.txt")) {
Ok { value } => println("loaded: ${value}"),
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 Err { error = ValidationError {
field = "name",
reason = "must not be empty",
}};
}
if (name.len() > 32) {
return Err { error = ValidationError {
field = "name",
reason = "must be 32 characters or fewer",
}};
}
Ok { value = name }
}

fun validate_age(raw: String) -> Result<i64, ValidationError> {
// Simplified: only accept a handful of values for this example.
match (raw) {
"17" => Err { error = ValidationError {
field = "age",
reason = "must be 18 or older",
}},
"25" => Ok { value = 25 },
"30" => Ok { value = 30 },
_ => 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)?;
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)) {
Ok { value } =>
println("ok: ${value.name}, age ${value.age}"),
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.