06. Error Handling

📋 Jump to Takeaways

🎁 Rust has no exceptions. No try/catch. No null. And somehow it's better at error handling than languages that have all three. Errors are just values — you return them, match on them, and the compiler makes sure you never ignore one by accident.

panic! vs Recoverable Errors

Rust splits errors into two categories: unrecoverable (bugs) and recoverable (expected failures).

// Unrecoverable — program crashes immediately
panic!("something went terribly wrong");

// Recoverable — caller decides what to do
let file = std::fs::read_to_string("config.toml");
// file is Result<String, io::Error>

Use panic! for programming errors — index out of bounds, impossible states. Use Result for anything that might fail in normal operation: file I/O, network calls, parsing.

The Result<T, E> Enum

Result is just an enum with two variants:

enum Result<T, E> {
    Ok(T),   // success, holds the value
    Err(E),  // failure, holds the error
}

Every function that can fail returns a Result. No exceptions flying through your call stack — the error is right there in the return type.

use std::fs;

fn read_config() -> Result<String, std::io::Error> {
    fs::read_to_string("config.toml")
}

Matching on Result

You handle a Result with pattern matching, just like Option.

match fs::read_to_string("config.toml") {
    Ok(contents) => println!("Config: {}", contents),
    Err(e) => println!("Failed to read config: {}", e),
}

The compiler won't let you use the success value without first checking whether it's actually there. No NullPointerException surprises.

unwrap and expect

When you're prototyping or you know a value must exist, unwrap and expect crash on error.

// Crashes with generic message if Err
let contents = fs::read_to_string("config.toml").unwrap();

// Crashes with YOUR message if Err
let contents = fs::read_to_string("config.toml")
    .expect("config.toml must exist in project root");

Use expect in tests and setup code where failure means the environment is broken. Avoid unwrap in production code — it gives no context when things go wrong.

The ? Operator

The ? operator propagates errors to the caller. If the value is Ok, it unwraps it. If it's Err, it returns early from the function with that error.

use std::fs;
use std::io;

fn read_username() -> Result<String, io::Error> {
    let contents = fs::read_to_string("username.txt")?;
    Ok(contents.trim().to_string())
}

Without ?, you'd need a match for every fallible call. With it, error propagation is a single character. You can chain multiple ? calls and the function returns the first error encountered.

fn setup() -> Result<String, io::Error> {
    let config = fs::read_to_string("config.toml")?;
    let db_url = fs::read_to_string("db_url.txt")?;
    let secret = fs::read_to_string("secret.key")?;
    Ok(format!("{}{}{}", config, db_url, secret))
}

Three potential failures, zero nested matches.

Multiple Error Types

Real functions often encounter different error types. You can't return Result<T, io::Error> if one call produces a parse error. Use Box<dyn Error> as a catch-all.

use std::error::Error;
use std::fs;

fn parse_port() -> Result<u16, Box<dyn Error>> {
    let contents = fs::read_to_string("port.txt")?;  // io::Error
    let port = contents.trim().parse::<u16>()?;       // ParseIntError
    Ok(port)
}

Box<dyn Error> accepts any type that implements the Error trait. You lose specific type info, but gain flexibility. This is fine for application code.

Custom Error Types

For libraries, you define your own error enum so callers can match on specific failures.

use std::fmt;
use std::num::ParseIntError;
use std::io;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
    NotFound(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
            AppError::NotFound(name) => write!(f, "Not found: {}", name),
        }
    }
}

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

// Convert from underlying errors automatically
impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

With From implementations, the ? operator automatically converts errors into your custom type.

fn load_config() -> Result<u16, AppError> {
    let contents = fs::read_to_string("port.txt")?;  // io::Error → AppError::Io
    let port = contents.trim().parse::<u16>()?;       // ParseIntError → AppError::Parse
    Ok(port)
}

Error Propagation Chain

Here's how errors flow through a real application:

fn read_port() -> Result<u16, AppError> {
    let text = fs::read_to_string("port.txt")?;
    let port = text.trim().parse::<u16>()?;
    if port < 1024 {
        return Err(AppError::NotFound("port must be >= 1024".into()));
    }
    Ok(port)
}

fn start_server() -> Result<(), AppError> {
    let port = read_port()?;
    println!("Starting on port {}", port);
    Ok(())
}

fn main() {
    match start_server() {
        Ok(()) => println!("Server running"),
        Err(e) => eprintln!("Failed to start: {}", e),
    }
}
// If port.txt is missing:   "Failed to start: IO error: No such file or directory"
// If port.txt has "abc":    "Failed to start: Parse error: invalid digit found in string"
// If port.txt has "80":     "Failed to start: Not found: port must be >= 1024"

Each layer adds context. The top-level main makes one decision: print success or print the error.

anyhow and thiserror

Writing all those From impls by hand gets tedious. The ecosystem has two crates that handle it:

thiserror — for libraries. Generates Display and From impls via derive macros.

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Parse error: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("Not found: {0}")]
    NotFound(String),
}

That's it. All the boilerplate from before, gone.

anyhow — for applications. When you don't need callers to match on specific error types.

use anyhow::{Context, Result};

fn read_port() -> Result<u16> {
    let text = std::fs::read_to_string("port.txt")
        .context("failed to read port.txt")?;
    let port = text.trim().parse::<u16>()
        .context("port.txt must contain a valid port number")?;
    Ok(port)
}

anyhow::Result<T> is shorthand for Result<T, anyhow::Error>. The .context() method adds human-readable messages to the error chain. Use thiserror when you're writing a library others consume. Use anyhow when you're writing an application and just need good error messages.

Key Takeaways

  • panic! is for bugs; Result<T, E> is for expected failures
  • Result is an enum — Ok(value) or Err(error) — no exceptions needed
  • unwrap/expect crash on error — use only in tests or when failure is impossible
  • ? propagates errors in one character — returns early on Err
  • Box<dyn Error> accepts any error type — good for quick application code
  • Custom error enums + From impls give callers precise control
  • thiserror generates boilerplate for library errors
  • anyhow simplifies error handling in applications with .context()

🎁 Next up: collections. You'll learn how Vec<T> grows dynamically without garbage collection, why HashMap is your go-to key-value store, and why String is surprisingly complicated — it's not just an array of characters.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy