Rust Error Handling: From Result to Custom Types

11 min read
#rust#error-handling#best-practices

Rust Error Handling: From Result to Custom Types

Rust's error handling forces you to think about failure modes. This isn't a flaw. It's a feature that catches bugs at compile time instead of in production. This post explores error handling patterns from basic to advanced.

The Foundation: Result

All error handling in Rust starts with

Result
:

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

A function either succeeds with a value or fails with an error. No exceptions. No null. No surprises.

fn parse_age(input: &str) -> Result<u32, std::num::ParseIntError> {
    input.trim().parse::<u32>()
}

fn main() {
    match parse_age("42") {
        Ok(age) => println!("Age: {}", age),
        Err(e) => eprintln!("Failed to parse: {}", e),
    }
}

This is explicit. Anyone reading this code knows it can fail. The compiler forces you to handle both cases.

The Question Mark Operator

Repeatedly matching Result is verbose. The

?
operator unwraps or returns early:

fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string(path)?;
    let trimmed = contents.trim().to_string();
    Ok(trimmed)
}

This is equivalent to:

fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let contents = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e.into()),
    };
    let trimmed = contents.trim().to_string();
    Ok(trimmed)
}

The

?
operator is much cleaner. Use it liberally in functions that return Result.

Custom Error Types

Generic error types work but lack context. Define custom error types for clarity:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    FileNotFound(String),
    InvalidFormat(String),
    MissingField(String),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::FileNotFound(path) => {
                write!(f, "Configuration file not found: {}", path)
            }
            ConfigError::InvalidFormat(reason) => {
                write!(f, "Invalid configuration format: {}", reason)
            }
            ConfigError::MissingField(field) => {
                write!(f, "Missing required field: {}", field)
            }
        }
    }
}

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

Now your error type describes the domain:

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let contents = std::fs::read_to_string(path)
        .map_err(|_| ConfigError::FileNotFound(path.to_string()))?;
    
    let config = parse_toml(&contents)
        .map_err(|e| ConfigError::InvalidFormat(e.to_string()))?;
    
    Ok(config)
}

Callers can pattern match on specific errors:

match load_config("app.toml") {
    Ok(config) => run_app(config),
    Err(ConfigError::FileNotFound(path)) => {
        eprintln!("Please create {}", path);
        std::process::exit(1);
    }
    Err(ConfigError::InvalidFormat(reason)) => {
        eprintln!("Fix the config: {}", reason);
        std::process::exit(1);
    }
    Err(ConfigError::MissingField(field)) => {
        eprintln!("Add {} to your config", field);
        std::process::exit(1);
    }
}

Error Context with anyhow

For libraries, use the

anyhow
crate to add context:

[dependencies]
anyhow = "1.0"
thiserror = "1.0"
use anyhow::{Context, Result};

fn connect_db(url: &str) -> Result<Database> {
    let db = Database::connect(url)
        .context("Failed to connect to database")?;
    
    db.ping()
        .context("Database connection failed health check")?;
    
    Ok(db)
}

fn main() -> Result<()> {
    let db = connect_db("postgres://...")?;
    // ... rest of code
    Ok(())
}

When this fails, the user sees:

Error: Failed to connect to database

Caused by:
    0: connection refused
    1: No such file or directory

Context makes debugging trivial.

The
thiserror
Crate

For defining error types elegantly:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Request timeout after {0}s")]
    Timeout(u64),
    
    #[error("Invalid request: {0}")]
    InvalidRequest(String),
    
    #[error("Server error: {status} {message}")]
    ServerError { status: u16, message: String },
    
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

No need to manually implement

Display
and
Error
.
#[derive(Error)]
does it for you.

The

#[from]
attribute auto-implements
From for ApiError
, allowing
?
to convert automatically:

fn read_file(path: &str) -> Result<String, ApiError> {
    // std::io::Error converts to ApiError automatically
    Ok(std::fs::read_to_string(path)?)
}

Pattern: Early Returns

Use early returns to avoid deep nesting:

// Bad: pyramid of doom
fn process_user(id: u32) -> Result<User> {
    match get_user(id) {
        Ok(user) => {
            match validate_user(&user) {
                Ok(_) => {
                    match update_permissions(&user) {
                        Ok(updated) => Ok(updated),
                        Err(e) => Err(e),
                    }
                }
                Err(e) => Err(e),
            }
        }
        Err(e) => Err(e),
    }
}

// Good: early returns with ?
fn process_user(id: u32) -> Result<User> {
    let user = get_user(id)?;
    validate_user(&user)?;
    let updated = update_permissions(&user)?;
    Ok(updated)
}

The second version is much clearer. The happy path is obvious.

Testing Error Cases

Test that functions fail appropriately:

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_parse_invalid_age() {
        let result = parse_age("not a number");
        assert!(result.is_err());
        
        match result {
            Err(e) => {
                assert_eq!(e.kind(), std::num::IntErrorKind::PosOverflow);
            }
            Ok(_) => panic!("Should have failed"),
        }
    }
    
    #[test]
    fn test_missing_config_file() {
        let result = load_config("/nonexistent/path.toml");
        assert!(matches!(result, Err(ConfigError::FileNotFound(_))));
    }
}

Don't just test the happy path. Test that errors are reported correctly.

Conclusion

Rust error handling is explicit by design. The benefits:

  1. Compile-time safety: Can't forget to handle errors
  2. Clear error types: Understand what can fail
  3. Context: Error messages tell you why
  4. Composability: Combine functions with
    ?

Start with

Result
. Add custom error types as your system grows. Use
anyhow
for libraries and
thiserror
for error definitions. Test both success and failure paths.

Rust's error handling philosophy reduces production bugs. Embrace it.