Apps Artificial Intelligence Cloud Computing CSS Cybersecurity Data Science Database Go JavaScript Linux Python Rust Software Engineering Web Development

Practical Error Handling in Rust with Result and the ? Operator

4 min read .
Practical Error Handling in Rust with Result and the ? Operator

Rust makes recoverable failure part of a function’s type. Instead of relying on exceptions, operations that can fail commonly return Result<T, E>, forcing callers to either handle the error or propagate it.

That explicitness can feel verbose at first. The ? operator and well-designed error types keep the code concise without hiding failure paths.

Result represents success or failure

Result<T, E> has two variants:

Ok(value)
Err(error)

Reading a file therefore returns either the file contents or an I/O error:

use std::fs;
use std::io;
use std::path::Path;

fn load(path: &Path) -> Result<String, io::Error> {
    match fs::read_to_string(path) {
        Ok(text) => Ok(text),
        Err(error) => Err(error),
    }
}

This version is correct but repetitive when the function simply wants to pass the error to its caller.

Propagate errors with ?

The same function can be written as:

fn load(path: &Path) -> Result<String, io::Error> {
    let text = fs::read_to_string(path)?;
    Ok(text)
}

If the read succeeds, ? unwraps the Ok value. If it fails, the function returns early with a compatible error.

? is not an instruction to ignore an error. It means the current layer chooses not to handle that error and transfers responsibility to its caller.

Handle errors at the layer with enough context

A low-level file loader usually cannot decide whether a missing file should terminate the program, trigger a retry, or display a message. It should return the error.

The application boundary can make that decision:

fn main() {
    let path = Path::new("config.txt");

    match load(path) {
        Ok(text) => println!("loaded {} bytes", text.len()),
        Err(error) => {
            eprintln!("failed to load {}: {error}", path.display());
            std::process::exit(1);
        }
    }
}

This keeps library code reusable and policy decisions near the user-facing boundary.

Add domain meaning with a custom error enum

Real functions often fail for several reasons. A configuration loader may encounter an I/O error or parse an invalid numeric value.

With only the standard library, define an enum:

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

#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    InvalidPort(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(error) => write!(f, "I/O error: {error}"),
            ConfigError::InvalidPort(error) => {
                write!(f, "invalid port: {error}")
            }
        }
    }
}

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

Then provide conversions so ? can translate source errors:

impl From<io::Error> for ConfigError {
    fn from(error: io::Error) -> Self {
        ConfigError::Io(error)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(error: ParseIntError) -> Self {
        ConfigError::InvalidPort(error)
    }
}

Now the function stays readable:

fn load_port(path: &Path) -> Result<u16, ConfigError> {
    let text = fs::read_to_string(path)?;
    let port = text.trim().parse::<u16>()?;
    Ok(port)
}

The compiler uses the From implementations to convert each error into ConfigError.

Preserve sources for debugging

A useful custom error can expose its underlying cause through Error::source:

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Io(error) => Some(error),
            ConfigError::InvalidPort(error) => Some(error),
        }
    }
}

This gives logging and reporting code access to the error chain without parsing display strings.

For larger applications, ecosystem crates can reduce custom-error boilerplate, but the standard-library model is worth understanding first because those libraries build on the same Error, From, and Result concepts.

Use panic for bugs, not ordinary failure

panic! is appropriate when the program reaches a state that violates an internal invariant and normal recovery is not meaningful. A missing user-provided file, failed network request, or invalid configuration value is usually an expected operational failure and belongs in Result.

unwrap() and expect() panic on Err. They are convenient in tests, prototypes, and places where failure is truly impossible by construction, but they should not replace normal error handling at runtime boundaries.

Do not erase useful error structure too early

Converting every error immediately to a plain string makes display easy but removes machine-readable distinctions.

Callers may need to distinguish “file not found” from “permission denied,” or a parse error from an I/O error. Preserve structured variants until the layer that actually turns the failure into a log message, exit status, HTTP response, or user interface message.

Common pitfalls

Catching errors too low in the stack

Printing an error inside a utility function prevents the caller from choosing retry, fallback, or user-facing behavior. Return failures unless the current layer owns the policy decision.

Using one giant error variant

An error enum should represent distinctions callers may reasonably care about. It does not need a unique variant for every string, but it should not collapse unrelated failure classes unnecessarily.

Losing context

io::Error may explain that an operation failed, but the caller may also need to know which configuration file was being loaded. Add context at appropriate boundaries while retaining the underlying source.

Treating ? as magic

The operator performs early return and conversion according to the function’s return type. If conversion is not defined, the compiler error is telling you that the error contracts do not line up yet.

Design errors as part of the API

A function’s error type communicates which failures are possible and which layer is expected to handle them. Good Rust error handling is therefore API design, not only syntax.

Return structured recoverable errors, use ? to propagate them cleanly, preserve their sources, and make final policy decisions at boundaries that have enough context to act.

Related Posts

chevron-up