Code of the Day
IntermediateOwnership in depth

Error handling

Propagate failures with Result<T,E>, the ? operator, and custom error types — Rust's exception-free approach to robust programs.

RustIntermediate12 min read
Recommended first
By the end of this lesson you will be able to:
  • Read and match on Result<T, E> values
  • Use the ? operator to propagate errors up the call stack
  • Implement From to enable automatic error conversions with ?
  • Define a custom error enum for a library or module

Rust has no exceptions. Functions that can fail return — an enum with two variants: Ok(value) on success and Err(error) on failure. This forces you to confront errors at the type level: the caller can see from the signature that this function might fail, and the compiler ensures they handle it.

This is the fundamentals track's "fail fast" principle made concrete: errors are values, they have types, and ignoring them is a compile error.

Result<T, E> basics

The standard library defines Result as:

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

A function that parses a number might return Result<i32, ParseIntError>. The caller handles both outcomes:

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.trim().parse::<u16>()
}

fn main() {
    match parse_port("8080") {
        Ok(port)  => println!("Listening on {}", port),
        Err(e)    => println!("Bad port: {}", e),
    }
}

match on a Result is exhaustive — the compiler requires you to handle both arms.

Convenience methods

Matching every Result by hand is verbose. The standard library provides shortcuts:

let n: i32 = "42".parse().unwrap();           // panics if Err — only use when you're certain
let n: i32 = "42".parse().expect("bad number"); // panics with a message
let n: i32 = "42".parse().unwrap_or(0);        // fallback value on Err
let n: i32 = "42".parse().unwrap_or_default();  // T::default() on Err

unwrap and expect are fine in tests and quick scripts. In library or production code, propagate the error instead.

Avoid unwrap() in library code. It the entire program on failure — the caller has no chance to recover. Use ? or explicit match to give callers control.

The ? operator

The is Rust's answer to boilerplate error propagation. It either unwraps an Ok value or immediately returns the Err from the current function:

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

fn read_username(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;   // returns Err early if reading fails
    let name = content.trim().to_string();
    Ok(name)
}

The ? after read_to_string(path) is equivalent to:

let content = match fs::read_to_string(path) {
    Ok(v)  => v,
    Err(e) => return Err(e.into()),
};

Notice .into()? calls Into::from on the error, so error types can be automatically converted. This is where the From trait comes in.

From for error conversions

When a function can fail with multiple error types, you define From conversions so ? can unify them:

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

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}

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

fn load_port(path: &str) -> Result<u16, AppError> {
    let content = std::fs::read_to_string(path)?;  // io::Error -> AppError via From
    let port: u16 = content.trim().parse()?;        // ParseIntError -> AppError via From
    Ok(port)
}

Both ? operators now automatically convert their errors into AppError thanks to the From implementations. The function has a single clean return type.

Custom error types

A minimal custom error only needs Debug. For user-facing messages, implement Display as well:

use std::fmt;

#[derive(Debug)]
struct ValidationError {
    field: String,
    message: String,
}

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "validation error on '{}': {}", self.field, self.message)
    }
}

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

Implementing the std::error::Error trait (which requires Debug + Display) makes your type compatible with the broader error-handling ecosystem, including third-party crates like anyhow and thiserror.

For real applications, consider the anyhow crate for quick, boxed, any-error propagation, and thiserror for library errors with clean derive macros. They build on the same Result/From primitives you've just learned — just less boilerplate.

Check your understanding

Knowledge check

  1. 1.
    What are the two variants of Result<T, E>?
  2. 2.
    What does the ? operator do when applied to a Result::Err value?
  3. 3.
    Using unwrap() in library code is acceptable because the caller can catch the panic.

Do it yourself

Write a function parse_config(input: &str) -> Result<(String, u16), AppError> that parses a string like "host:8080" into a hostname and port. Use ? to propagate both the split-format error and the port parse error. Define your own AppError enum.

When the agent's away

Common error-handling patterns at the command line:

# Run and see the error type printed via Debug
cargo run 2>&1

# The ? operator only works in functions that return Result (or Option).
# In main(), add -> Result<(), Box<dyn std::error::Error>> to use ? there too.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let n: i32 = "42".parse()?;
    println!("{}", n);
    Ok(())
}

Where to go next

You've completed the ownership-in-depth module. Next, the types and traits module starts with enums and pattern matching — where Result and Option are just the beginning of what Rust's enum system can express.

Finished reading? Mark it complete to track your progress.

On this page