Skip to content

Chapter 7: Error Handling

The Problem with Errors

Things go wrong. Files don't exist. Network requests fail. Users enter invalid data. Disks fill up. Memory runs out.

How you handle these failures determines whether your program crashes spectacularly or degrades gracefully.

Traditional approaches are bad:

Return codes:

wyn
fn divide(a: int, b: int) -> int {
    if (b == 0) {
        return -1  // Error indicator
    }
    return a / b
}

Problem: -1 might be a valid result. And you can forget to check it.

Exceptions:

try {
    divide(10, 0)
} catch (e) {
    // Handle error
}

Problem: Exceptions are invisible. You can't tell from the function signature that it might fail. They're also expensive and complicate control flow.

Wyn uses a better approach: explicit error types.

Result: Success or Failure

Result<T, E> represents an operation that can succeed with a value of type T or fail with an error of type E:

wyn
fn divide(a: int, b: int) -> Result<int, string> {
    if (b == 0) {
        return Err("Division by zero")
    }
    return Ok(a / b)
}

fn main() -> int {
    var result = divide(10, 2)
    
    match result {
        Ok(value) => {
            print("Result: ")
            print(value)
        },
        Err(message) => {
            print("Error: ")
            print(message)
        }
    }
    
    return 0
}

The type system forces you to handle both cases. You can't accidentally ignore an error.

Why This Is Better

  1. Explicit: The function signature tells you it can fail
  2. Type-safe: The compiler ensures you handle both cases
  3. No exceptions: Control flow is obvious
  4. Composable: Results can be chained and transformed

This is how Rust does it. It's how Go does it (sort of). It's the right way.

Option: Maybe There's a Value

Option<T> represents a value that might not exist. You can also use the shorthand int? syntax:

wyn
var x: int? = Some(42)
var y: int? = None()
println(x.unwrap().to_string())       // 42
println(y.unwrap_or(0).to_string())   // 0

Here's a more complete example with pattern matching:

wyn
fn find_user(id: int) -> Option<string> {
    if (id == 1) {
        return Some("Alice")
    }
    return None
}

fn main() -> int {
    var user = find_user(1)
    
    match user {
        Some(name) => {
            print("Found: ")
            print(name)
        },
        None => {
            print("User not found")
        }
    }
    
    return 0
}

This is better than returning null or a special value. The type system forces you to check.

Option vs Null

In languages with null:

var name = get_name()
print(name.len())  // Crashes if name is null!

With Option:

wyn
var name = get_name()  // Returns Option<string>
match name {
    Some(n) => print(n.len()),  // Safe
    None => print("No name")
}

The compiler won't let you forget to check.

The ? Operator: Error Propagation

When you have multiple operations that can fail, you don't want to nest match statements:

wyn
// Ugly: nested matches
fn process() -> Result<int, string> {
    var result1 = operation1()
    match result1 {
        Ok(val1) => {
            var result2 = operation2(val1)
            match result2 {
                Ok(val2) => {
                    return Ok(val2)
                },
                Err(e) => return Err(e)
            }
        },
        Err(e) => return Err(e)
    }
}

The ? operator does this automatically:

wyn
fn process() -> Result<int, string> {
    var val1 = operation1()?  // If error, return early
    var val2 = operation2(val1)?
    return Ok(val2)
}

If operation1() returns an error, ? returns that error from process(). If it returns Ok(value), ? unwraps the value and assigns it to val1.

This is syntactic sugar, but it's incredibly useful sugar.

How ? Works

wyn
var value = operation()?

// Conceptually expands to:
var result = operation()
var value = match result {
    Ok(v) => v,
    Err(e) => return Err(e)
}

The ? operator:

  1. Evaluates the expression
  2. If Ok, unwraps the value
  3. If Err, returns early with that error

Chaining Operations

With ?, you can chain fallible operations cleanly:

wyn
fn load_config(path: string) -> Result<Config, string> {
    var data = read_config_file(path)?
    var validated = validate_syntax(data)?
    var config = parse_config(validated)?
    return Ok(config)
}

Each ? propagates errors up. If any step fails, the function returns that error. If all succeed, you get the final result.

This is how you write solid code without drowning in error handling.

Practical Error Handling

Validation

wyn
fn validate_email(email: string) -> Result<string, string> {
    if (not email.contains("@")) {
        return Err("Email must contain @")
    }
    if (not email.contains(".")) {
        return Err("Email must contain .")
    }
    if (email.len() < 5) {
        return Err("Email too short")
    }
    return Ok(email)
}

fn main() -> int {
    var result = validate_email("[email protected]")
    match result {
        Ok(email) => print("Valid: " + email),
        Err(msg) => print("Invalid: " + msg)
    }
    return 0
}

File Operations

wyn
fn read_config(path: string) -> Result<string, string> {
    if (path == "") {
        return Err("Empty path")
    }
    
    // Simplified file reading
    var contents = File.read(path)
    
    if (contents == "") {
        return Err("Empty file")
    }
    
    return Ok(contents)
}

Network Requests

wyn
fn fetch_user(id: int) -> Result<User, string> {
    if (id <= 0) {
        return Err("Invalid user ID")
    }
    
    var response = Http.get("https://example.com/users/${id}")
    var user = parse_user(response)?
    
    return Ok(user)
}

Error Recovery

Sometimes you want to provide a fallback:

wyn
fn get_config_value(key: string) -> string {
    var result = read_config(key)
    
    if result.is_ok() {
        return result.unwrap()
    }
    return "default"  // Fallback
}

Or retry:

wyn
fn retry_operation(max_attempts: int) -> Result<string, string> {
    var attempts = 0
    
    while (attempts < max_attempts) {
        var result = risky_operation()
        
        match result {
            Ok(value) => return Ok(value),
            Err(_) => {
                attempts = attempts + 1
                // Try again
            }
        }
    }
    
    return Err("Max attempts exceeded")
}

Option Methods

unwrap_or: Provide a Default

wyn
fn main() -> int {
    var maybe_name = find_user(999)
    var name = maybe_name.unwrap_or("Guest")
    print(name)  // "Guest"
    return 0
}

is_some and unwrap: Transform the Value

To transform a value only when it's present, check with is_some() and unwrap:

wyn
fn double_opt(o: int?) -> int? {
    if o.is_some() {
        return Some(o.unwrap() * 2)
    }
    return None
}

fn main() -> int {
    var maybe_num = Some(5)
    var doubled = double_opt(maybe_num)
    
    match doubled {
        Some(n) => print(n),  // 10
        None => print("No value")
    }
    
    return 0
}

Calling unwrap() on a None crashes, so only call it after is_some() returns true (or use unwrap_or for a safe default).

Chaining Optional Operations

The same pattern chains operations that each might produce nothing:

wyn
fn parse_int(s: string) -> Option<int> {
    if (s == "42") {
        return Some(42)
    }
    return None
}

fn main() -> int {
    var maybe_str = Some("42")
    
    var result: int? = None
    if maybe_str.is_some() {
        result = parse_int(maybe_str.unwrap())
    }
    
    match result {
        Some(n) => print(n),  // 42
        None => print("Failed")
    }
    
    return 0
}

If maybe_str is None, the chain short-circuits and result stays None. No crashes, no null checks scattered everywhere.

When to Use Result vs Option

Use Result when:

  • An operation can fail
  • You need to know why it failed
  • You want to propagate errors with ?

Use Option when:

  • A value might not exist
  • Absence is not an error
  • You don't need error details

Examples:

wyn
// Result: operation can fail
fn divide(a: int, b: int) -> Result<int, string>

// Option: value might not exist
fn find_user(id: int) -> Option<User>

// Result: parsing can fail
fn parse_json(text: string) -> Result<Json, string>

// Option: search might not find anything
fn array_find(arr: [int], target: int) -> Option<int>

Error Messages: Make Them Useful

Bad error messages:

wyn
return Err("Error")
return Err("Failed")
return Err("Invalid input")

Good error messages:

wyn
return Err("Division by zero")
return Err("File not found: " + path)
return Err("Invalid email: must contain @")

Include context. Tell the user what went wrong and why.

What You've Learned

Errors are values in Wyn. Result<T, E> for operations that can fail, Option<T> for values that might not exist.

The ? operator propagates errors automatically. Use it to chain fallible operations without nesting.

Pattern matching forces you to handle both success and failure. The compiler won't let you forget.

Good error handling is about making failures explicit, providing useful messages, and recovering gracefully.

Exercises

  1. Safe division: Write a function that divides two numbers and returns Result<int, string>. Handle division by zero.

  2. Array search: Write a function that searches an array and returns Option<int> with the index if found, None otherwise.

  3. Email validator: Write a function that validates email addresses using Result. Check for @, ., and minimum length.

  4. Chain operations: Write three functions that return Result. Chain them with ? in a fourth function.

  5. Retry logic: Implement a retry mechanism that attempts an operation up to N times before giving up.

  6. Option to Result: Write a function that converts Option<T> to Result<T, string> with a custom error message.

  7. Error recovery: Write a function that tries to read a config file. If it fails, create a default config and save it.


Next: Chapter 8: Pattern Matching

MIT License - v1.19.1