Frontend Labs
⋯

10. Error handling: Result, ?, and panics

10.1 Result<T, E>

use std::num::ParseIntError;

fn parse_kv(line: &str) -> Result<(String, u32), String> {
    let (k, v) = line.split_once('=').ok_or(format!("missing '=' in {line:?}"))?;
    let v = v.trim();
    let v: u32 = v.parse().map_err(|e: ParseIntError| format!("bad value {v:?}: {e}"))?;
    Ok((k.trim().to_string(), v))
}

fn main() {
    for line in ["workers = 8", "timeout", "retries = many"] {
        match parse_kv(line) {
            Ok((k, v)) => println!("{k} -> {v}"),
            Err(e) => println!("error: {e}"),
        }
    }
}

The ? operator means "if this is Err(e), return Err(e.into()) from the current function, otherwise unwrap the Ok value". It does the same job as Go's if err != nil { return err }, in one character, and it calls From::from to convert the error type on the way out.

Go C Rust
Signal failure return nil, err return -1; errno = EINVAL return Err(e)
Propagate if err != nil { return err } if (rc < 0) goto fail; ?
Can you ignore it? yes, v, _ := f() yes, very easily #[must_use] warning; you must write let _ = explicitly
Wrap with context fmt.Errorf("...: %w", err) — .context("...") (anyhow), or a source field (thiserror)
Inspect errors.Is / errors.As compare errno match on the enum, downcast_ref

10.2 Custom error types with thiserror (libraries)

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("cannot read config file {path}")]
    Io { path: String, #[source] source: std::io::Error },
    #[error("line {line}: {msg}")]
    Syntax { line: usize, msg: String },
    #[error("unknown key {0:?}")]
    UnknownKey(String),
}

fn load(path: &str) -> Result<String, ConfigError> {
    std::fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.into(), source })
}

fn main() {
    match load("/definitely/missing.toml") {
        Err(ConfigError::Io { path, source }) => println!("io error on {path}: {source}"),
        Err(e) => println!("{e}"),
        Ok(s) => println!("{} bytes", s.len()),
    }
    println!("{}", ConfigError::Syntax { line: 3, msg: "expected '='".into() });
    println!("{}", ConfigError::UnknownKey("colour".into()));
}

10.3 anyhow for applications

use anyhow::{bail, Context, Result};

fn read_port(path: &str) -> Result<u16> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("reading port file {path}"))?;
    let port: u16 = text.trim().parse().context("port file is not a number")?;
    if port < 1024 { bail!("refusing privileged port {port}"); }
    Ok(port)
}

fn main() {
    if let Err(e) = read_port("/nonexistent/port") {
        // {:#} prints the whole chain: "reading port file ...: No such file or directory (os error 2)"
        println!("{e:#}");
    }
}

The rule most Rust teams use: libraries define precise error enums with thiserror, so callers can match on them. Binaries use anyhow::Result and add .context(...) at each layer, so the final message reads like a stack of reasons.

10.4 Panics are for bugs

panic!, unwrap(), expect(), out-of-bounds indexing, and integer overflow in debug builds all panic. By default a panic unwinds the thread's stack and runs Drop for every live value. If it reaches the top of main, the process exits with code 101.

fn main() {
    let config_from_build: Option<&str> = Some("prod");
    // expect() documents WHY you believe this cannot fail. Prefer it to unwrap().
    let env = config_from_build.expect("build script always sets the environment");

    let r = std::panic::catch_unwind(|| {
        let v: Vec<i32> = Vec::new();
        v[0] // index out of bounds: panic
    });
    println!("{env}, caught panic: {}", r.is_err());
    // The default panic hook still prints "index out of bounds: the len is 0 but
    // the index is 0" to stderr. catch_unwind stops the unwinding, not the message.
}

catch_unwind is for boundaries, such as a thread pool keeping a worker alive or an FFI function that must not unwind into C. It is not a try/catch for normal error handling.

When to panic: a broken invariant (a bug), tests, prototypes, and startup code that cannot continue. When to return Result: anything caused by input, the environment, or the network. A panic in a request handler is a denial-of-service bug (Ch 51). Axum and Tokio isolate panics per task, but you should not rely on that.

10.5 panic = "abort" and FFI

With panic = "abort" in the profile, a panic kills the process instead of unwinding. You get smaller binaries and no catch_unwind. A panic must never unwind across an extern "C" boundary into C code. Since Rust 1.81 that aborts the process (Ch 34).


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36