Error Handling

Nostos promotes robust error handling through its type system, favoring explicit error representation over exceptions for recoverable errors. For unrecoverable errors, a panic mechanism is available, and for boundary situations, try/catch blocks can be used.

The Option Type (No Nulls!)

Nostos does not have null or nil. Instead, it uses the Option[T] type to represent the possible absence of a value. This forces you to explicitly handle both cases, eliminating a common source of bugs.


# Option[T] is built-in: Some(T) | None()

find_item(list, target) =
    if contains(list, target) then Some(target)
    else None()

main() = {
    my_list = [1, 2, 3]
    found = find_item(my_list, 2)
    not_found = find_item(my_list, 4)

    # Handle Option using pattern matching
    result1 = match found {
        Some(val) -> "Found: " ++ show(val)
        None() -> "Not found"
    }
    println(result1)  # "Found: 2"

    result2 = match not_found {
        Some(val) -> "Found: " ++ show(val)
        None() -> "Not found"
    }
    println(result2)  # "Not found"
}

Option Helper Methods

The standard library provides convenient methods for working with Option values. These can be called using method syntax on Option values.

use stdlib.list.*

main() = {
    some_val = Some(10)
    none_val: Option[Int] = None()

    # map - transform the value inside Some
    doubled = some_val.map(x => x * 2)  # Some(20)

    # flatMap - chain operations that return Option
    result = some_val.flatMap(x => if x > 5 { Some(x + 100) } else { None() })  # Some(110)

    # unwrap - get value or panic if None
    val = some_val.unwrap()  # 10

    # unwrapOr - get value or return default
    val1 = some_val.unwrapOr(0)  # 10
    val2 = none_val.unwrapOr(0)  # 0

    # isSome / isNone - check if value present
    if some_val.isSome() { println("Has value") }
    if none_val.isNone() { println("No value") }
}

Option Methods Summary

  • opt.map(fn) - Transform value: Some(x) → Some(fn(x)), None → None
  • opt.flatMap(fn) - Chain: Some(x) → fn(x), None → None (where fn returns Option)
  • opt.unwrap() - Get value or panic
  • opt.unwrapOr(default) - Get value or return default
  • opt.isSome() - Returns true if Some
  • opt.isNone() - Returns true if None

The Result Type (Recoverable Errors)

For operations that can fail in a way you might want to recover from, Nostos uses the Result[T, E] type. It's either Ok(T) for a successful result or Err(E) for an error.


# Result[T, E] is built-in: Ok(T) | Err(E)

# Custom error type as a variant
type DivisionError = DivideByZero(String)

safe_divide(numerator, denominator) =
    if denominator == 0 then Err(DivideByZero("Cannot divide by zero"))
    else Ok(numerator / denominator)

main() = {
    division_ok = safe_divide(10, 2)
    division_err = safe_divide(10, 0)

    result_ok = match division_ok {
        Ok(val) -> "Result: " ++ show(val)
        Err(DivideByZero(msg)) -> "Error: " ++ msg
    }
    println(result_ok)  # "Result: 5"

    result_err = match division_err {
        Ok(val) -> "Result: " ++ show(val)
        Err(DivideByZero(msg)) -> "Error: " ++ msg
    }
    println(result_err)  # "Error: Cannot divide by zero"
}

Result Helper Methods

The standard library provides convenient methods for working with Result values, similar to Option. These can be called using method syntax on Result values.

use stdlib.list.*

main() = {
    ok_val: Result[Int, String] = Ok(42)
    err_val: Result[Int, String] = Err("something went wrong")

    # map - transform the success value
    doubled = ok_val.map(x => x * 2)  # Ok(84)
    err_doubled = err_val.map(x => x * 2)  # Err("something went wrong")

    # mapErr - transform the error value
    new_err = err_val.mapErr(e => "Error: " ++ e)  # Err("Error: something went wrong")

    # flatMap - chain operations that return Result
    chained = ok_val.flatMap(x => if x > 0 { Ok(x * 10) } else { Err("negative") })  # Ok(420)

    # unwrap - get value or panic if Err
    val = ok_val.unwrap()  # 42

    # unwrapOr - get value or return default
    val1 = ok_val.unwrapOr(0)  # 42
    val2 = err_val.unwrapOr(0)  # 0

    # isOk / isErr - check result status
    if ok_val.isOk() { println("Success!") }
    if err_val.isErr() { println("Failed!") }

    # toOption - convert Result to Option (discards error)
    opt = ok_val.toOption()  # Some(42)
    opt2 = err_val.toOption()  # None()
}

Result Methods Summary

  • res.map(fn) - Transform success: Ok(x) → Ok(fn(x)), Err(e) → Err(e)
  • res.mapErr(fn) - Transform error: Ok(x) → Ok(x), Err(e) → Err(fn(e))
  • res.flatMap(fn) - Chain: Ok(x) → fn(x), Err(e) → Err(e) (where fn returns Result)
  • res.unwrap() - Get value or panic
  • res.unwrapOr(default) - Get value or return default
  • res.isOk() - Returns true if Ok
  • res.isErr() - Returns true if Err
  • res.toOption() - Converts Ok(x) to Some(x), Err to None

The ? Operator (Error Propagation)

The ? operator provides a convenient shorthand for propagating errors from functions that return Result. If the Result is Err, it returns the error immediately; otherwise, it unwraps the Ok value.


# (Assume safe_divide and DivideByZeroError from above)

calculate_complex(a, b, c) = {
    val1 = safe_divide(a, b)?   # Returns Err if b is 0
    val2 = safe_divide(val1, c)? # Returns Err if c is 0
    Ok(val1 + val2)
}

main() = {
    res1 = calculate_complex(10, 2, 1) # Ok(7)
    res2 = calculate_complex(10, 0, 1) # Err(DivideByZeroError) from first call
    res3 = calculate_complex(10, 2, 0) # Err(DivideByZeroError) from second call

    println(res1)
    println(res2)
    println(res3)
}

Throw (Catchable Exceptions)

Use throw to raise exceptions that can be caught with try/catch. You can throw any value - strings, numbers, or custom error types.

# Throw with a string message
safe_divide(a, b) =
    if b == 0 then throw("division by zero")
    else a / b

# Throw with a custom error type
type ApiError = { code: Int, msg: String }

fetch_user(id) =
    if id < 0 then throw(ApiError(404, "not found"))
    else "User " ++ id.show()

main() = {
    # These throws can be caught with try/catch
    result = try { safe_divide(10, 0) } catch { e -> "Error: " ++ e }
    println(result)  # "Error: division by zero"
}

Panic (Unrecoverable Errors)

Use panic for truly unrecoverable situations - programmer errors, violated invariants, or bugs that should never happen. Unlike throw, panics indicate logic errors that shouldn't be caught and handled normally.

# Panic on invariant violation
validate_positive(n) =
    if n <= 0 then panic("Expected a positive number, got " ++ n.show())
    else n

# Pattern matching exhaustiveness
unwrap(Some(x)) = x
unwrap(None) = panic("called unwrap on None")

main() = {
    valid = validate_positive(5)
    # This will cause a panic and terminate the process
    # invalid = validate_positive(-1)
}

throw vs panic

  • throw - Expected errors that callers should handle (invalid input, network failure, file not found)
  • panic - Bugs and invariant violations that should never occur in correct code

try/catch (Boundary Error Handling)

While Result is for recoverable errors in pure code, try/catch provides a mechanism to catch and handle exceptions. It's primarily used for I/O operations and when interacting with external systems.


risky_operation(should_panic) =
    if should_panic then panic("Something went really wrong!")
    else "Operation successful"

main() = {
    result1 = try { risky_operation(false) }
        catch { err -> "Caught error: " ++ err.show() }
    println(result1) # "Operation successful"

    result2 = try { risky_operation(true) }
        catch { err -> "Caught error: " ++ err.show() }
    println(result2) # "Caught error: Something went really wrong!"
}

I/O Error Handling

All I/O operations in Nostos (file, network, HTTP) throw exceptions on error. This keeps the happy path clean while ensuring errors are handled explicitly with try/catch.


# File I/O with exception handling
read_config(path) = {
    try {
        content = File.readAll(path)
        println("Config loaded: " ++ content)
        Some(content)
    } catch { e -> {
        println("Failed to read config: " ++ e)
        None
    } }
}

# HTTP requests with error handling
fetch_data(url) = {
    try {
        response = Http.get(url)
        if response.status == 200 then
            Some(response.body)
        else
            None
    } catch { e -> {
        println("Network error: " ++ e)
        None
    } }
}

# Combining multiple I/O operations
process_file(input_path, output_path) = {
    try {
        data = File.readAll(input_path)
        processed = String.toUpper(data)
        File.writeAll(output_path, processed)
        println("Processing complete!")
        true
    } catch { e -> {
        println("Error: " ++ e)
        false
    } }
}

main() = {
    read_config("/etc/myapp/config.txt")
    fetch_data("https://api.example.com/data")
    process_file("/tmp/input.txt", "/tmp/output.txt")
}

Design Note: I/O operations throw exceptions because they interact with the external world where failures are common (network timeouts, missing files, permission errors). Using exceptions keeps your code clean while ensuring you explicitly handle failure cases at appropriate boundaries.