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

Error Handling

Flint treats errors as values. There are no exceptions, no stack unwinding, and no throw. Functions that can fail say so in their return type, and you handle the failure at the call site.

Result<T, E> and Option<T>

These two types cover the error model:

// A function that might fail
fn read_config(path: string) -> Result<Config, Error> { ... }

// A function that might not find a value
fn lookup(key: string) -> Option<string> { ... }

Constructors:

return Ok(value)      // success
return Ok()           // success with unit value
return Err("message") // failure with Error
return Some(value)    // present
return None           // absent

The ? Operator

? is shorthand for: “if this is Err or None, return it immediately from the enclosing function.”

With Result

fn process(path: string) -> Result<(), Error> {
    let text = io.read_text(path)?     // propagates Err automatically
    let config = Config.parse(text)?   // propagates Err automatically
    apply(config)
    return Ok()
}

Without ? it would be:

fn process(path: string) -> Result<(), Error> {
    let text = match io.read_text(path) {
        Ok(t) => t
        Err(e) => return Err(e)
    }
    // ...
}

With Option

? also works with Option<T>. In that case, None returns early from the enclosing function:

fn read_port() -> Option<u16> {
    let raw = env.lookup("PORT")?
    let parsed = i32.parse(raw).ok()?
    return u16.try_from(parsed).ok()
}

This example returns:

  • None if PORT is missing
  • None if parsing fails
  • None if the parsed value does not fit in u16
  • Some(port) on success

The enclosing function must return a compatible Result or Option.

The Error Type

Error is Flint’s standard general-purpose error type. It is always in scope with no import needed. It is equivalent to an immutable string message:

fn connect(addr: string) -> Result<Connection, Error> {
    if addr.is_empty() {
        return Err("address cannot be empty")
    }
    // ...
}

You can use domain-specific error enums for richer modeling:

enum ParseError {
    UnexpectedEof
    UnexpectedToken { found: string, expected: string }
    InvalidNumber { text: string }
}

fn parse(src: string) -> Result<Ast, ParseError> { ... }

Result and Option Methods

You do not always need a full match. Option<T> and Result<T, E> have a small set of built-in helpers for the most common cases.

Check the State

Use these when you only need to ask whether a value is present or successful:

let found = some_option.is_some()
let missing = some_option.is_none()

let ok = some_result.is_ok()
let failed = some_result.is_err()

Provide a Default

Use unwrap_or when you want a fallback value instead of branching:

let port = maybe_port.unwrap_or(8080)
let retries = parsed_retries.unwrap_or(3)

Transform the Success Value

Use map when you want to keep the container shape and transform only the success or present value:

let upper = username.map(|name| {
    return name.upper()
}) // Option<string>

let doubled = reading.map(|value| {
    return value * 2
}) // Result<u16, Error>

Chain Another Fallible Step

Use and_then when the closure itself returns another Option or Result:

let home_dir = env.lookup("HOME").and_then(|path| {
    return normalize_path(path)
}) // Option<string>

let config = io.read_text("flint.toml").and_then(|text| {
    return Config.parse(text)
}) // Result<Config, Error>

Convert Option Into Result

Use ok_or when absence should become an error:

let port = env.lookup("PORT").ok_or("PORT is required")

Transform the Error Value

Use map_err when the success case is fine but you want a different error type:

let reading = read_sensor().map_err(|err| -> SensorError {
    return SensorError.ReadFailed { message: err }
})

Recover From an Error

Use or_else when you want to replace one failure with another attempt:

let config = load_primary_config().or_else(|_| {
    return load_backup_config()
})

These methods cover most day-to-day cases:

  • inspect: is_some, is_none, is_ok, is_err
  • default: unwrap_or
  • transform success: map
  • chain another step: and_then
  • convert absence into failure: ok_or
  • transform failure: map_err
  • recover from failure: or_else

If the logic starts branching in several directions, switch back to match. The methods are for simple pipelines, not for hiding complex control flow.

Fatal Errors

For bugs and unrecoverable states, not for normal error handling:

// Crash with a message (never returns)
fatal("invariant violated: queue empty")

// Crash with an Error value
fatal_error(err)

// Assert a condition (crashes if false)
assert(buffer.len() <= MAX_SIZE)

// Mark unreachable code paths
match direction {
    Direction.Left  => turn_left()
    Direction.Right => turn_right()
    _               => unreachable()   // tells compiler this cannot happen
}

Fatal errors do not unwind. On MCU targets they emit a compact diagnostic record and then trap. They are a last-resort debugging path, not a substitute for Result.

Conventions

  • Functions that can fail at runtime return Result<T, Error> or a domain-specific Result<T, MyError>.
  • Functions that return optional data return Option<T>.
  • Do not return Result for operations that cannot fail. Unnecessary noise makes code harder to read.
  • Use ? liberally to propagate errors up the call stack without boilerplate.
  • Use fatal and assert for bug detection, not for expected runtime conditions.
// Good
fn parse_port(s: string) -> Result<u16, Error> {
    let n = i32.parse(s)?
    return u16.try_from(n).ok_or("port out of range")
}

// Avoid: defensive Result for operations that cannot fail
fn add(a: i32, b: i32) -> Result<i32, Error> {  // unnecessary
    return Ok(a + b)
}