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:
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:
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
- Explicit: The function signature tells you it can fail
- Type-safe: The compiler ensures you handle both cases
- No exceptions: Control flow is obvious
- 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:
var x: int? = Some(42)
var y: int? = None()
println(x.unwrap().to_string()) // 42
println(y.unwrap_or(0).to_string()) // 0Here's a more complete example with pattern matching:
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:
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:
// 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:
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
var value = operation()?
// Conceptually expands to:
var result = operation()
var value = match result {
Ok(v) => v,
Err(e) => return Err(e)
}The ? operator:
- Evaluates the expression
- If
Ok, unwraps the value - If
Err, returns early with that error
Chaining Operations
With ?, you can chain fallible operations cleanly:
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
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
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
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:
fn get_config_value(key: string) -> string {
var result = read_config(key)
if result.is_ok() {
return result.unwrap()
}
return "default" // Fallback
}Or retry:
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
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:
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:
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:
// 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:
return Err("Error")
return Err("Failed")
return Err("Invalid input")Good error messages:
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
Safe division: Write a function that divides two numbers and returns
Result<int, string>. Handle division by zero.Array search: Write a function that searches an array and returns
Option<int>with the index if found,Noneotherwise.Email validator: Write a function that validates email addresses using Result. Check for @, ., and minimum length.
Chain operations: Write three functions that return Result. Chain them with
?in a fourth function.Retry logic: Implement a retry mechanism that attempts an operation up to N times before giving up.
Option to Result: Write a function that converts
Option<T>toResult<T, string>with a custom error message.Error recovery: Write a function that tries to read a config file. If it fails, create a default config and save it.