Chapter 6: Error Handling & Design Patterns

Robust error handling and well-designed patterns are essential for production Rust applications. This chapter covers advanced error handling techniques and common design patterns.

6.1 Custom Error Types

Manual Error Implementation

use std::fmt;

#[derive(Debug)]
enum DatabaseError {
    ConnectionFailed(String),
    QueryFailed(String),
    NotFound,
}

impl fmt::Display for DatabaseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            DatabaseError::ConnectionFailed(msg) => {
                write!(f, "Connection failed: {}", msg)
            }
            DatabaseError::QueryFailed(msg) => {
                write!(f, "Query failed: {}", msg)
            }
            DatabaseError::NotFound => {
                write!(f, "Record not found")
            }
        }
    }
}

impl std::error::Error for DatabaseError {}

fn query_database(id: u64) -> Result<String, DatabaseError> {
    if id == 0 {
        Err(DatabaseError::NotFound)
    } else {
        Ok(format!("User {}", id))
    }
}

Using thiserror

use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] DatabaseError),

    #[error("IO error")]
    Io(#[from] std::io::Error),

    #[error("Invalid configuration: {field}")]
    InvalidConfig { field: String },

    #[error("User {id} not found")]
    UserNotFound { id: u64 },
}

fn load_user(id: u64) -> Result<String, AppError> {
    if id == 0 {
        return Err(AppError::UserNotFound { id });
    }
    Ok(format!("User {}", id))
}

6.2 Error Conversion and Propagation

Using anyhow for Applications

use anyhow::{Context, Result, bail};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context("Failed to read config file")?;

    let config: Config = serde_json::from_str(&content)
        .context("Failed to parse config")?;

    if config.port == 0 {
        bail!("Invalid port number");
    }

    Ok(config)
}

#[derive(serde::Deserialize)]
struct Config {
    port: u16,
    host: String,
}
thiserror vs anyhow

Custom Error Context

trait ResultExt<T> {
    fn with_context<F, S>(self, f: F) -> Result<T, String>
    where
        F: FnOnce() -> S,
        S: Into<String>;
}

impl<T, E: fmt::Display> ResultExt<T> for Result<T, E> {
    fn with_context<F, S>(self, f: F) -> Result<T, String>
    where
        F: FnOnce() -> S,
        S: Into<String>,
    {
        self.map_err(|e| format!("{}: {}", f().into(), e))
    }
}

fn example() -> Result<(), String> {
    let content = std::fs::read_to_string("config.json")
        .with_context(|| "Failed to read config")?;
    Ok(())
}

6.3 Builder Pattern

struct HttpClient {
    base_url: String,
    timeout: std::time::Duration,
    headers: Vec<(String, String)>,
    retry_count: u32,
}

struct HttpClientBuilder {
    base_url: Option<String>,
    timeout: std::time::Duration,
    headers: Vec<(String, String)>,
    retry_count: u32,
}

impl HttpClientBuilder {
    fn new() -> Self {
        Self {
            base_url: None,
            timeout: std::time::Duration::from_secs(30),
            headers: Vec::new(),
            retry_count: 3,
        }
    }

    fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = Some(url.into());
        self
    }

    fn timeout(mut self, duration: std::time::Duration) -> Self {
        self.timeout = duration;
        self
    }

    fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((key.into(), value.into()));
        self
    }

    fn retry_count(mut self, count: u32) -> Self {
        self.retry_count = count;
        self
    }

    fn build(self) -> Result<HttpClient, String> {
        Ok(HttpClient {
            base_url: self.base_url.ok_or("base_url is required")?,
            timeout: self.timeout,
            headers: self.headers,
            retry_count: self.retry_count,
        })
    }
}

fn main() {
    let client = HttpClientBuilder::new()
        .base_url("https://api.example.com")
        .timeout(std::time::Duration::from_secs(10))
        .header("User-Agent", "MyApp/1.0")
        .retry_count(5)
        .build()
        .unwrap();
}

6.4 Type State Pattern

use std::marker::PhantomData;

// States
struct Created;
struct Connected;
struct Authenticated;

struct Database<State = Created> {
    connection_string: String,
    _state: PhantomData<State>,
}

impl Database<Created> {
    fn new(connection_string: String) -> Self {
        Database {
            connection_string,
            _state: PhantomData,
        }
    }

    fn connect(self) -> Result<Database<Connected>, String> {
        println!("Connecting to {}", self.connection_string);
        Ok(Database {
            connection_string: self.connection_string,
            _state: PhantomData,
        })
    }
}

impl Database<Connected> {
    fn authenticate(self, password: &str) -> Result<Database<Authenticated>, String> {
        if password == "secret" {
            Ok(Database {
                connection_string: self.connection_string,
                _state: PhantomData,
            })
        } else {
            Err("Authentication failed".to_string())
        }
    }
}

impl Database<Authenticated> {
    fn query(&self, sql: &str) -> Result<Vec<String>, String> {
        println!("Executing: {}", sql);
        Ok(vec!["result1".to_string(), "result2".to_string()])
    }
}

fn main() {
    let db = Database::new("localhost:5432".to_string())
        .connect()
        .unwrap()
        .authenticate("secret")
        .unwrap();

    db.query("SELECT * FROM users").unwrap();

    // Won't compile: can't query before authentication!
    // let db = Database::new("localhost:5432".to_string());
    // db.query("SELECT * FROM users");
}

6.5 Strategy Pattern

trait CompressionStrategy {
    fn compress(&self, data: &[u8]) -> Vec<u8>;
    fn decompress(&self, data: &[u8]) -> Vec<u8>;
}

struct GzipCompression;
struct ZstdCompression;

impl CompressionStrategy for GzipCompression {
    fn compress(&self, data: &[u8]) -> Vec<u8> {
        println!("Compressing with gzip");
        data.to_vec() // Simplified
    }

    fn decompress(&self, data: &[u8]) -> Vec<u8> {
        println!("Decompressing with gzip");
        data.to_vec()
    }
}

impl CompressionStrategy for ZstdCompression {
    fn compress(&self, data: &[u8]) -> Vec<u8> {
        println!("Compressing with zstd");
        data.to_vec()
    }

    fn decompress(&self, data: &[u8]) -> Vec<u8> {
        println!("Decompressing with zstd");
        data.to_vec()
    }
}

struct Compressor {
    strategy: Box<dyn CompressionStrategy>,
}

impl Compressor {
    fn new(strategy: Box<dyn CompressionStrategy>) -> Self {
        Compressor { strategy }
    }

    fn compress(&self, data: &[u8]) -> Vec<u8> {
        self.strategy.compress(data)
    }
}

fn main() {
    let data = b"Hello, World!";

    let compressor = Compressor::new(Box::new(GzipCompression));
    compressor.compress(data);

    let compressor = Compressor::new(Box::new(ZstdCompression));
    compressor.compress(data);
}

6.6 Command Pattern

trait Command {
    fn execute(&mut self) -> Result<(), String>;
    fn undo(&mut self) -> Result<(), String>;
}

struct Counter {
    value: i32,
}

struct IncrementCommand {
    counter: *mut Counter,
    amount: i32,
}

impl Command for IncrementCommand {
    fn execute(&mut self) -> Result<(), String> {
        unsafe {
            (*self.counter).value += self.amount;
        }
        Ok(())
    }

    fn undo(&mut self) -> Result<(), String> {
        unsafe {
            (*self.counter).value -= self.amount;
        }
        Ok(())
    }
}

struct CommandHistory {
    commands: Vec<Box<dyn Command>>,
    position: usize,
}

impl CommandHistory {
    fn new() -> Self {
        CommandHistory {
            commands: Vec::new(),
            position: 0,
        }
    }

    fn execute(&mut self, mut command: Box<dyn Command>) -> Result<(), String> {
        command.execute()?;
        self.commands.truncate(self.position);
        self.commands.push(command);
        self.position += 1;
        Ok(())
    }

    fn undo(&mut self) -> Result<(), String> {
        if self.position == 0 {
            return Err("Nothing to undo".to_string());
        }
        self.position -= 1;
        self.commands[self.position].undo()
    }
}

6.7 Visitor Pattern

trait Visitor {
    fn visit_file(&mut self, file: &File);
    fn visit_directory(&mut self, dir: &Directory);
}

trait Visitable {
    fn accept(&self, visitor: &mut dyn Visitor);
}

struct File {
    name: String,
    size: u64,
}

struct Directory {
    name: String,
    children: Vec<Box<dyn Visitable>>,
}

impl Visitable for File {
    fn accept(&self, visitor: &mut dyn Visitor) {
        visitor.visit_file(self);
    }
}

impl Visitable for Directory {
    fn accept(&self, visitor: &mut dyn Visitor) {
        visitor.visit_directory(self);
        for child in &self.children {
            child.accept(visitor);
        }
    }
}

struct SizeCalculator {
    total_size: u64,
}

impl Visitor for SizeCalculator {
    fn visit_file(&mut self, file: &File) {
        self.total_size += file.size;
    }

    fn visit_directory(&mut self, _dir: &Directory) {
        // Directory itself has no size
    }
}

6.8 Dependency Injection

trait Logger {
    fn log(&self, message: &str);
}

struct ConsoleLogger;
struct FileLogger {
    path: String,
}

impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("[LOG] {}", message);
    }
}

impl Logger for FileLogger {
    fn log(&self, message: &str) {
        println!("[FILE] Writing to {}: {}", self.path, message);
    }
}

struct UserService<L: Logger> {
    logger: L,
}

impl<L: Logger> UserService<L> {
    fn new(logger: L) -> Self {
        UserService { logger }
    }

    fn create_user(&self, name: &str) {
        self.logger.log(&format!("Creating user: {}", name));
        // Create user logic
    }
}

fn main() {
    let console_service = UserService::new(ConsoleLogger);
    console_service.create_user("Alice");

    let file_service = UserService::new(FileLogger {
        path: "app.log".to_string(),
    });
    file_service.create_user("Bob");
}

6.9 Option and Result Combinators

fn combinator_examples() {
    // map: transform the value
    let value = Some(5).map(|x| x * 2);  // Some(10)

    // and_then: chain operations
    let result = Some(5)
        .and_then(|x| if x > 0 { Some(x * 2) } else { None });

    // or: provide alternative
    let value = None.or(Some(42));  // Some(42)

    // unwrap_or: default value
    let value = None.unwrap_or(0);  // 0

    // map_or: transform with default
    let value = Some("hello").map_or(0, |s| s.len());  // 5

    // ok_or: convert Option to Result
    let result: Result<i32, &str> = Some(42).ok_or("not found");
}

fn result_combinators() -> Result<i32, String> {
    // Chaining with ?
    let value = parse_number("42")?;
    let doubled = multiply_by_two(value)?;
    Ok(doubled)
}

fn parse_number(s: &str) -> Result<i32, String> {
    s.parse().map_err(|e| format!("Parse error: {}", e))
}

fn multiply_by_two(n: i32) -> Result<i32, String> {
    Ok(n * 2)
}

6.10 Chapter Summary

Key Takeaways