Files
lux/docs/guide/08-errors.md
Brandon Lucas 44f88afcf8 docs: add comprehensive language documentation
Documentation structure inspired by Rust Book, Elm Guide, and others:

Guide (10 chapters):
- Introduction and setup
- Basic types (Int, String, Bool, List, Option, Result)
- Functions (closures, higher-order, composition)
- Data types (ADTs, pattern matching, records)
- Effects (the core innovation)
- Handlers (patterns and techniques)
- Modules (imports, exports, organization)
- Error handling (Fail, Option, Result)
- Standard library reference
- Advanced topics (traits, generics, optimization)

Reference:
- Complete syntax reference

Tutorials:
- Calculator (parsing, evaluation, REPL)
- Dependency injection (testing with effects)
- Project ideas (16 projects by difficulty)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 17:43:41 -05:00

6.7 KiB

Chapter 8: Error Handling

Errors happen. Lux provides multiple ways to handle them, all explicit in the type system.

The Fail Effect

The simplest error handling uses the built-in Fail effect:

fn divide(a: Int, b: Int): Int with {Fail} =
    if b == 0 then Fail.fail("Division by zero")
    else a / b

fn main(): Unit with {Console} = {
    let result = run divide(10, 2) with {}
    Console.print("Result: " + toString(result))
}

When b == 0, Fail.fail stops execution. The program terminates with an error.

Fail Propagates

fn helper(): Int with {Fail} =
    Fail.fail("Oops")

fn caller(): Int with {Fail} = {
    let x = helper()  // Fails here
    x * 2             // Never reached
}

If you call a function with Fail, you must declare it:

// Error: caller uses Fail but doesn't declare it
fn broken(): Int = {
    divide(10, 0)
}

Option - Maybe There's a Value

For operations that might not have a result:

fn safeDivide(a: Int, b: Int): Option<Int> =
    if b == 0 then None
    else Some(a / b)

fn findUser(id: Int): Option<User> =
    // Returns None if user doesn't exist
    Database.query("SELECT * FROM users WHERE id = " + toString(id))

Working with Option

Pattern matching:

fn showResult(opt: Option<Int>): String =
    match opt {
        Some(n) => "Got: " + toString(n),
        None => "No value"
    }

Option methods:

let x = Some(5)
let y: Option<Int> = None

Option.map(x, fn(n: Int): Int => n * 2)  // Some(10)
Option.map(y, fn(n: Int): Int => n * 2)  // None

Option.getOrElse(x, 0)  // 5
Option.getOrElse(y, 0)  // 0

Option.isSome(x)  // true
Option.isNone(y)  // true

Chaining with flatMap:

fn getUserName(id: Int): Option<String> =
    Option.flatMap(findUser(id), fn(user: User): Option<String> =>
        Some(user.name)
    )

Result<T, E> - Success or Failure

For operations that can fail with an error value:

fn parseNumber(s: String): Result<Int, String> =
    if isNumeric(s) then Ok(parseInt(s))
    else Err("Not a number: " + s)

fn readConfig(path: String): Result<Config, String> with {File} =
    if File.exists(path) then
        match parseConfig(File.read(path)) {
            Some(c) => Ok(c),
            None => Err("Invalid config format")
        }
    else Err("Config file not found: " + path)

Working with Result

Pattern matching:

fn handleResult(r: Result<Int, String>): String =
    match r {
        Ok(n) => "Success: " + toString(n),
        Err(e) => "Error: " + e
    }

Result methods:

let success = Ok(42)
let failure: Result<Int, String> = Err("oops")

Result.map(success, fn(n: Int): Int => n * 2)  // Ok(84)
Result.map(failure, fn(n: Int): Int => n * 2)  // Err("oops")

Result.getOrElse(success, 0)  // 42
Result.getOrElse(failure, 0)  // 0

Result.isOk(success)   // true
Result.isErr(failure)  // true

Chaining operations:

fn processData(input: String): Result<Output, String> = {
    let parsed = parseInput(input)          // Result<Input, String>
    let validated = Result.flatMap(parsed, validate)  // Result<Input, String>
    Result.map(validated, transform)         // Result<Output, String>
}

Combining Approaches

Option to Result

fn optionToResult<T>(opt: Option<T>, error: String): Result<T, String> =
    match opt {
        Some(v) => Ok(v),
        None => Err(error)
    }

fn findUserOrError(id: Int): Result<User, String> =
    optionToResult(findUser(id), "User not found: " + toString(id))

Result to Option

fn resultToOption<T, E>(r: Result<T, E>): Option<T> =
    match r {
        Ok(v) => Some(v),
        Err(_) => None
    }

Fail with Result

fn processOrFail(input: String): Output with {Fail} =
    match process(input) {
        Ok(output) => output,
        Err(e) => Fail.fail(e)
    }

Error Handling Patterns

Early Return with Fail

fn validateUser(user: User): User with {Fail} = {
    if user.name == "" then Fail.fail("Name required")
    else if user.age < 0 then Fail.fail("Invalid age")
    else if user.email == "" then Fail.fail("Email required")
    else user
}

fn registerUser(user: User): UserId with {Fail, Database} = {
    let validated = validateUser(user)
    Database.insert(validated)
}

Collecting Errors

fn validateAll(user: User): Result<User, List<String>> = {
    let errors: List<String> = []
    let errors = if user.name == "" then List.concat(errors, ["Name required"]) else errors
    let errors = if user.age < 0 then List.concat(errors, ["Invalid age"]) else errors
    let errors = if user.email == "" then List.concat(errors, ["Email required"]) else errors

    if List.isEmpty(errors) then Ok(user)
    else Err(errors)
}

Default Values

fn getConfig(key: String): String =
    Option.getOrElse(Config.get(key), "default")

fn getPort(): Int =
    Result.getOrElse(parseNumber(Process.env("PORT")), 8080)

Logging Errors

fn processWithLogging(input: String): Result<Output, String> with {Console} = {
    let result = process(input)
    match result {
        Ok(_) => result,
        Err(e) => {
            Console.print("Error processing input: " + e)
            result
        }
    }
}

Custom Error Types

Define specific error types:

type ValidationError =
    | MissingField(String)
    | InvalidFormat(String, String)  // field, expected format
    | OutOfRange(String, Int, Int)   // field, min, max

fn validate(user: User): Result<User, ValidationError> = {
    if user.name == "" then Err(MissingField("name"))
    else if user.age < 0 then Err(OutOfRange("age", 0, 150))
    else if !isValidEmail(user.email) then Err(InvalidFormat("email", "user@domain.com"))
    else Ok(user)
}

fn showError(e: ValidationError): String =
    match e {
        MissingField(f) => "Missing required field: " + f,
        InvalidFormat(f, fmt) => f + " must be in format: " + fmt,
        OutOfRange(f, min, max) =>
            f + " must be between " + toString(min) + " and " + toString(max)
    }

When to Use What

Scenario Use
Might not exist Option<T>
Can fail with reason Result<T, E>
Fatal error, stop execution Fail effect
Multiple error types Custom error ADT

Summary

// Option - maybe a value
let opt: Option<Int> = Some(42)
let none: Option<Int> = None

// Result - success or error
let ok: Result<Int, String> = Ok(42)
let err: Result<Int, String> = Err("failed")

// Fail effect - abort execution
fn risky(): Int with {Fail} = Fail.fail("boom")

// Pattern match to handle
match result {
    Ok(v) => handleSuccess(v),
    Err(e) => handleError(e)
}

Next

Chapter 9: Standard Library - Built-in functions and modules.