Error Handling
On this page 33
Home provides a comprehensive error handling system that combines the explicitness of Result types with the convenience of modern language features. This approach ensures errors are never silently ignored while maintaining ergonomic code.
Overview
Home's error handling philosophy:
- Explicit error paths: Errors are part of the type signature
- No hidden control flow: No exceptions that unwind the stack unexpectedly
- Composable errors: Error types can be combined and transformed
- Zero-cost when unused: No overhead for code paths that don't error
The Result Type
Basic Result Usage
enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_number(s: string) -> Result<i32, ParseError> {
// Implementation
}
fn main() {
let result = parse_number("42")
match result {
Ok(n) => print("Parsed: {n}"),
Err(e) => print("Error: {e}"),
}
}
The ? Operator
Propagate errors concisely with the ? operator:
fn read_config() -> Result<Config, Error> {
let file = File.open("config.json")? // Returns early if Err
let contents = file.read_to_string()?
let config = json.parse(contents)?
Ok(config)
}
Result Methods
let result: Result<i32, Error> = Ok(42)
// Transform success value
let doubled = result.map(|n| n _ 2) // Ok(84)
// Transform error value
let with_context = result.map_err(|e| Error.wrap(e, "context"))
// Chain operations
let final_result = result
.and_then(|n| validate(n))
.and_then(|n| process(n))
// Provide defaults
let value = result.unwrap_or(0)
let value = result.unwrap_or_else(|e| {
log.warn("Using default: {e}")
0
})
// Convert to Option
let maybe = result.ok() // Some(42) or None
Defining Error Types
Simple Error Structs
struct FileError {
path: string,
kind: FileErrorKind,
source: ?Box<dyn Error>,
}
enum FileErrorKind {
NotFound,
PermissionDenied,
InvalidFormat,
IoError,
}
impl Error for FileError {
fn message(&self) -> string {
match self.kind {
FileErrorKind.NotFound => "File not found: {self.path}",
FileErrorKind.PermissionDenied => "Permission denied: {self.path}",
FileErrorKind.InvalidFormat => "Invalid format: {self.path}",
FileErrorKind.IoError => "IO error: {self.path}",
}
}
fn source(&self) -> ?&dyn Error {
self.source.as_deref()
}
}
Error Enums
enum DatabaseError {
ConnectionFailed { host: string, port: u16 },
QueryFailed { query: string, message: string },
Timeout { duration: Duration },
InvalidData { expected: string, got: string },
}
impl Error for DatabaseError {
fn message(&self) -> string {
match self {
DatabaseError.ConnectionFailed { host, port } => {
"Failed to connect to {host}:{port}"
}
DatabaseError.QueryFailed { query, message } => {
"Query failed: {message}\nQuery: {query}"
}
DatabaseError.Timeout { duration } => {
"Operation timed out after {duration}"
}
DatabaseError.InvalidData { expected, got } => {
"Invalid data: expected {expected}, got {got}"
}
}
}
}
The Error Trait
trait Error {
fn message(&self) -> string
fn source(&self) -> ?&dyn Error { null }
fn chain(&self) -> ErrorChain {
ErrorChain { current: Some(self) }
}
}
struct ErrorChain<'a> {
current: ?&'a dyn Error,
}
impl Iterator for ErrorChain<'_> {
type Item = &dyn Error
fn next(mut self) -> ?Self.Item {
let current = self.current?
self.current = current.source()
Some(current)
}
}
Error Conversion
From Trait for Errors
impl From<IoError> for AppError {
fn from(e: IoError) -> AppError {
AppError.Io(e)
}
}
impl From<ParseError> for AppError {
fn from(e: ParseError) -> AppError {
AppError.Parse(e)
}
}
// Now ? automatically converts
fn process_file(path: &str) -> Result<Data, AppError> {
let file = File.open(path)? // IoError -> AppError
let text = file.read_to_string()? // IoError -> AppError
let data = parse(text)? // ParseError -> AppError
Ok(data)
}
The #[derive(Error)] Macro
# [derive(Error, Debug)]
enum ServiceError {
#[error("Network error: {0}")]
Network(#[from] NetworkError),
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Validation failed: {message}")]
Validation { message: string },
#[error("Not found: {resource}")]
NotFound { resource: string },
}
Error Context
Adding Context to Errors
trait ResultExt<T, E> {
fn context(self, msg: string) -> Result<T, ContextError<E>>
fn with_context<F: Fn() -> string>(self, f: F) -> Result<T, ContextError<E>>
}
fn read_config(path: &str) -> Result<Config, Error> {
let contents = std.fs.read_to_string(path)
.context("Failed to read config file")?
let config = json.parse(&contents)
.with_context(|| "Failed to parse config at {path}")?
Ok(config)
}
Error Chains
fn load_user(id: u64) -> Result<User, Error> {
let row = database.query("SELECT _ FROM users WHERE id = ?", id)
.context("Failed to query database")?
let user = User.from_row(row)
.context("Failed to parse user data")?
Ok(user)
}
// When error occurs, chain shows:
// Error: Failed to parse user data
// Caused by: Invalid date format in 'created_at' field
// Caused by: Expected ISO 8601 format
Panic and Unrecoverable Errors
When to Panic
// Use panic for programming errors, not runtime errors
fn get_element(arr: []i32, index: usize) -> i32 {
if index >= arr.len() {
panic("Index {index} out of bounds for array of length {arr.len()}")
}
arr[index]
}
// Assertions for invariants
fn process_positive(n: i32) -> i32 {
assert(n > 0, "Expected positive number, got {n}")
n * 2
}
// Debug assertions (removed in release builds)
fn optimized_path(data: []u8) {
debug_assert(data.len() >= 4, "Data too short")
// ...
}
Catching Panics
use std.panic
fn risky_operation() {
panic("Something went wrong!")
}
fn main() {
let result = panic.catch(|| {
risky_operation()
42
})
match result {
Ok(value) => print("Got: {value}"),
Err(panic_info) => print("Caught panic: {panic_info}"),
}
}
Custom Panic Handlers
# [panic_handler]
fn custom_panic(info: &PanicInfo) -> never {
// Log the panic
log.error("PANIC: {info}")
// In embedded systems, might reset
#[cfg(embedded)]
system.reset()
// Otherwise abort
std.process.abort()
}
Try Blocks
Explicit Try Scopes
fn process() -> Result<Output, Error> {
let result = try {
let a = operation_a()?
let b = operation_b(a)?
let c = operation_c(b)?
c
}
match result {
Ok(output) => {
log.info("Success")
Ok(output)
}
Err(e) => {
log.error("Failed: {e}")
Err(e)
}
}
}
Try Blocks with Different Error Types
fn mixed_errors() -> Result<Data, AppError> {
// Convert errors within try block
let data = try {
let file_content = read_file()? // IoError
let parsed = parse_json(file_content)? // ParseError
let validated = validate(parsed)? // ValidationError
validated
}.map_err(|e| AppError.from(e))?
Ok(data)
}
Optional Error Handling
The Option Type
fn find_user(id: u64) -> ?User {
database.users.get(id)
}
fn main() {
// Pattern matching
match find_user(123) {
Some(user) => print("Found: {user.name}"),
None => print("User not found"),
}
// Optional chaining
let name = find_user(123)?.profile?.display_name
// Default values
let user = find_user(123) ?? User.guest()
// If-let
if let Some(user) = find_user(123) {
send_welcome_email(user)
}
}
Converting Between Option and Result
// Option to Result
let result = maybe_value.ok_or(Error.new("No value"))
let result = maybe_value.ok_or_else(|| compute_error())
// Result to Option
let maybe = result.ok() // Discards error
let maybe = result.err() // Gets error, discards success
// Transpose nested types
let nested: ?Result<i32, Error> = Some(Ok(42))
let transposed: Result<?i32, Error> = nested.transpose() // Ok(Some(42))
Advanced Patterns
Error Aggregation
fn validate_all(items: []Item) -> Result<[], Vec<ValidationError>> {
let mut errors = Vec.new()
for item in items {
if let Err(e) = validate(item) {
errors.push(e)
}
}
if errors.is_empty() {
Ok([])
} else {
Err(errors)
}
}
Retry Logic
fn retry<T, E>(
attempts: u32,
delay: Duration,
operation: fn() -> Result<T, E>
) -> Result<T, E> {
let mut last_error: ?E = null
for attempt in 1..=attempts {
match operation() {
Ok(value) => return Ok(value),
Err(e) => {
log.warn("Attempt {attempt} failed: {e}")
last_error = Some(e)
if attempt < attempts {
std.thread.sleep(delay)
}
}
}
}
Err(last_error.unwrap())
}
Fallback Chains
fn get_config() -> Result<Config, Error> {
read_from_file("config.json")
.or_else(|_| read_from_env())
.or_else(|_| read_from_defaults())
}
Integration Patterns
With Async Code
async fn fetch_data(url: &str) -> Result<Data, Error> {
let response = http.get(url).await?
let body = response.text().await?
let data = json.parse(body)?
Ok(data)
}
async fn fetch_with_fallback(urls: []&str) -> Result<Data, Error> {
for url in urls {
match fetch_data(url).await {
Ok(data) => return Ok(data),
Err(e) => log.warn("Failed to fetch from {url}: {e}"),
}
}
Err(Error.new("All sources failed"))
}
With Resources
fn with_transaction<T>(f: fn(&Transaction) -> Result<T, Error>) -> Result<T, Error> {
let tx = database.begin_transaction()?
match f(&tx) {
Ok(result) => {
tx.commit()?
Ok(result)
}
Err(e) => {
tx.rollback()?
Err(e)
}
}
}
Best Practices
-
Use Result for recoverable errors, panic for bugs:
// Good: File might not exist fn read_file(path: &str) -> Result<string, IoError> // Good: Index out of bounds is a bug fn get(arr: []T, i: usize) -> T { assert(i < arr.len()) arr[i] } -
Create domain-specific error types:
// Good: Clear error domain enum UserServiceError { NotFound { id: UserId }, InvalidCredentials, AccountLocked { until: DateTime }, } // Avoid: Generic errors fn get_user() -> Result<User, string> -
Provide context at error sites:
file.write_all(data) .context("Failed to save user preferences")? -
Don't ignore errors:
// Bad: Error ignored let _ = file.write(data) // Good: Explicit handling if let Err(e) = file.write(data) { log.warn("Failed to write: {e}") } -
Use early returns for clarity:
fn process(input: Input) -> Result<Output, Error> { if !input.is_valid() { return Err(Error.invalid_input()) } let intermediate = step_one(input)? let result = step_two(intermediate)? Ok(result) }