Errors

Rust has no exceptions. Failures are values, returned through the type system, and impossible to ignore. This is the single thing that pulls more bugs out of Rust code than every other feature combined.

Two types do most of the work: Option<T> and Result<T, E>.

Option<T>: value or no value

Use it when a value may legitimately be absent.

fn find_user(id: u64) -> Option<User> {
    if let Some(u) = users().get(&id) {
        Some(u.clone())
    } else {
        None
    }
}
 
match find_user(42) {
    Some(u) => println!("Found {}", u.name),
    None => println!("No such user"),
}

Option<T> is an enum:

enum Option<T> {
    Some(T),
    None,
}

The compiler refuses to let you "just use it" as if it always had a value. You must handle both arms.

Result<T, E>: success or failure

Use it when an operation may fail.

fn parse_port(s: &str) -> Result<u16, ParsePortError> {
    let n = s.parse::<u16>().map_err(|_| ParsePortError)?;
    if n < 1024 {
        return Err(ParsePortError);
    }
    Ok(n)
}

Result<T, E> is also an enum:

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

The convention: Ok is success, Err is failure. Same shape as Option, different semantics.

The ? operator: return early if Err

The single most important piece of Rust syntax for error handling.

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let bytes = std::fs::read(path)?;            // io::Error -> ConfigError
    let s = std::str::from_utf8(&bytes)?;        // Utf8Error -> ConfigError
    let config: Config = serde_json::from_str(s)?;  // serde::Error -> ConfigError
    Ok(config)
}

Each ? says: "if this is Err, return the error to my caller. Otherwise unwrap the Ok and continue."

For ? to work, the inner error type must convert into the outer error type. The From<InnerError> impl handles that. With thiserror, you write:

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("utf8: {0}")]
    Utf8(#[from] std::str::Utf8Error),
    #[error("parse: {0}")]
    Parse(#[from] serde_json::Error),
}

The #[from] attribute generates the From impl automatically. Now ? propagates each inner error into the right variant.

unwrap and expect: convenient and risky

let port: u16 = s.parse().unwrap();      // Panic on Err.
let port: u16 = s.parse().expect("invalid port");  // Panic with a message.

Both panic on the Err case. That is sometimes fine in main, in tests, in throwaway scripts. It is almost never fine in library code, because your panic crashes your caller.

The Sail workspace enforces this with a lint:

[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "deny"

In tests, Sail opts out locally with #[expect(clippy::unwrap_used)] (note: expect, not allowexpect is itself an error if the lint stops firing, so dead opt-outs cannot accumulate).

The full toolkit

MethodWhat it does
.unwrap()Panic on Err / None.
.expect(msg)Panic with msg on Err / None.
.unwrap_or(default)Return default on Err / None.
.unwrap_or_else(|e| ...)Compute default from the error.
.unwrap_or_default()Use Default::default() on Err / None.
.ok()Convert Result<T, E> to Option<T>, dropping the error.
.err()Convert Result<T, E> to Option<E>.
.map(|x| ...)Transform the Ok / Some value.
.map_err(|e| ...)Transform the error.
.and_then(|x| ...)Chain another fallible operation.
.or_else(|e| ...)Recover from the error.
?Propagate. The default tool.

For real code: lean on ? and map_err. Reach for the others only when they fit.

thiserror vs anyhow

Two crates dominate.

thiserror for library errors. Define a specific enum with concrete variants. Callers can match on each variant.

#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
    #[error("not found: {0}")]
    NotFound(String),
    #[error("already exists: {0}")]
    AlreadyExists(String),
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
}

anyhow for application errors. anyhow::Result<T> is Result<T, anyhow::Error> where anyhow::Error is "any error, with context." Cheap to throw, expensive to match on.

fn main() -> anyhow::Result<()> {
    let cfg = load_config("config.toml")
        .context("failed to load config")?;
    run(cfg).context("server crashed")?;
    Ok(())
}

Rule of thumb: thiserror in libraries, anyhow in main.rs and CLI entry points. Never anyhow in library APIs.