Skip to content

Error Handling ​

Milo has no exceptions and no null. If a function can fail, its return type says so, and the compiler won't let you ignore it.

Result basics ​

Fallible functions return Result<T, E>: either Ok(value) or Err(error).

milo
from "std/fs" import { readFile }
from "std/io" import { IoError }
from "std/strconv" import { parseInt }

fn readNumber(path: &string): Result<i64, IoError> {
    let text = readFile(path)?
    match parseInt(text.trim()) {
        Some(n) => { return Ok(n) }
        None => { return Err(IoError.Other("not a number")) }
    }
}

Ok(n) and Err(e) may drop the Result. prefix; see Option and Result.

Three operators handle a Result at the call site: ? propagates, ! unwraps or panics, ?? falls back to a default. The rest of this page shows each one, then match for full control.

? — propagate ​

On error, ? returns it to the caller immediately. Only works inside functions that themselves return Result.

milo
from "std/fs" import { readFile }
from "std/io" import { IoError }

fn loadConfig(path: &string): Result<string, IoError> {
    let text = readFile(path)?     // on error, returns it
    return Ok(text)
}

! — unwrap or panic ​

Crashes on error. Appropriate for top-level code, scripts, or when you've already validated the input.

milo
from "std/fs" import { readFile }

fn main(): i32 {
    let text = readFile("count.txt")!   // panic if the file is missing
    print(text)
    return 0
}

?? — default on error ​

Discards the error and substitutes a value.

milo
from "std/fs" import { readFile }

fn main(): i32 {
    let text = readFile("count.txt") ?? "0"   // missing file? use "0"
    print(text)
    return 0
}

match — full control ​

When you need different behavior for success and failure:

milo
from "std/fs" import { readFile }
from "std/io" import { IoError }
from "std/strconv" import { parseInt }

fn readNumber(path: &string): Result<i64, IoError> {
    let text = readFile(path)?
    match parseInt(text.trim()) {
        Some(n) => { return Ok(n) }
        None => { return Err(IoError.Other("not a number")) }
    }
}

fn main(): i32 {
    match readNumber("count.txt") {
        Ok(n)  => { return n as i32 }
        Err(e) => {
            print("error: ", e)
            return 1
        }
    }
}

Typed errors ​

The default Result<T> carries a string message. When you need to branch on the cause of failure, define an error enum:

milo
// Sketch: `...` stands in for the body. std/io already defines IoError with these
// variants plus IsDirectory, AlreadyExists and Other.
enum IoError {
    NotFound(string),
    PermissionDenied(string),
}

fn readFile(path: string): Result<string, IoError> { ... }

Callers match on specific failure modes. Patterns do not nest, so bind the error and match it in a second step:

milo
from "std/fs" import { readFile }
from "std/io" import { IoError }

fn parse(data: string) {
    print("parsed ", data.len, " bytes")
}

fn useDefaults() {
    print("using defaults")
}

match readFile("config.toml") {
    Ok(data) => { parse(data) }
    Err(e) => {
        match e {
            IoError.NotFound(_)         => { useDefaults() }
            IoError.PermissionDenied(p) => { print("denied: ", p) }
            _                           => { print("other error") }
        }
    }
}

Auto-conversion with ? ​

When your error enum wraps another error type, ? converts automatically:

milo
from "std/fs" import { readFile }
from "std/io" import { IoError }

enum ParseError {
    BadNumber(string),
}

enum AppError {
    Io(IoError),         // wraps IoError
    Parse(ParseError),   // wraps ParseError
}

fn parseNum(text: string): Result<i32, ParseError> {
    return Ok(text.len as i32)
}

fn process(path: string): Result<i32, AppError> {
    let text = readFile(path)?        // IoError -> AppError.Io, automatic
    let n = parseNum(text)?           // ParseError -> AppError.Parse, automatic
    return Ok(n)
}

In Rust, this requires the thiserror crate or hand-written From implementations. In Milo, the compiler generates the conversion automatically.

Boxed errors and .context ​

When a caller only needs to report an error, one enum per layer is too much. The prelude declares interface Error { fn message(self: &Self): string }, and a fn that returns Result<T, Heap<Error>> takes any error through ?: a struct or enum with a message method is boxed, and a plain string error arrives as an ErrorMessage. r.context("note") adds a layer on the way up; its message reads outermost first.

milo
struct NotFound { path: string }
impl NotFound {
    fn message(self: &Self): string { return "not found: " + self.path }
}

fn readConfig(path: string): Result<string, NotFound> {
    return Err(NotFound { path: path })
}

fn parse(text: string): Result<i64, string> {
    return Ok(text.len)
}

fn load(path: string): Result<i64, Heap<Error>> {
    let text = readConfig(path).context("loading config")?   // NotFound, boxed
    let n = parse(text).context("parsing")?                   // string, boxed as ErrorMessage
    return Ok(n)
}

fn main() {
    match load("x.toml") {
        Ok(n) => { print(n) }
        Err(e) => { print(e.message()) }   // loading config: not found: x.toml
    }
}

This is the anyhow shape: Heap<Error> where the type no longer matters, a typed enum where a caller will match on it.