Error Handling

Designing Robust Error APIs

Designing a robust error API means choosing return types, error representations, and type invariants so callers can reliably handle failures, diagnose root causes, and upgrade dependencies without breaking existing code.

Library Error APIs vs. Application Error Handling

Dimension Public Library Crate Application / Binary
Primary Audience Downstream Rust code that inspects variants programmatically Human operators and log aggregators
Error Representation Concrete structured enum or struct implementing std::error::Error Unified error or type-erased Box<dyn Error + Send + Sync>
Granularity Scoped per subsystem or operation (ConnectError, QueryError) Top-level contextual error chain
Evolution Marked #[non_exhaustive] so adding variants is non-breaking Internal; variants can be modified freely
Panicking Never panics on invalid input; panics only on documented contract violations May panic on startup misconfiguration or unrecoverable invariant bugs

Parse, Don't Validate: Encoding Invariants in Types

Instead of validating raw inputs (such as &str or u16) repeatedly across multiple functions—forcing every downstream function to return Result—parse the raw input once at the boundary into a validated newtype whose private field guarantees the invariant holds everywhere else:

Raw Input (&str) ──> Username::new(raw) ──┬── Err(UsernameError)   (handled at boundary)
                                          └── Ok(Username)         (infallible everywhere else)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonEmptyUsername(String);

#[derive(Debug, PartialEq, Eq)]
pub enum UsernameError {
    Empty,
    TooLong { len: usize, max: usize },
}

impl NonEmptyUsername {
    pub const MAX_LEN: usize = 16;

    pub fn new(raw: &str) -> Result<Self, UsernameError> {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            return Err(UsernameError::Empty);
        }
        if trimmed.len() > Self::MAX_LEN {
            return Err(UsernameError::TooLong {
                len: trimmed.len(),
                max: Self::MAX_LEN,
            });
        }
        Ok(Self(trimmed.to_string()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

// Downstream functions accept `NonEmptyUsername` and never need to return `Result`:
fn greet_user(user: &NonEmptyUsername) -> String {
    format!("Welcome, {}!", user.as_str())
}

fn main() {
    for candidate in ["  alice  ", "", "username_that_is_way_too_long"] {
        match NonEmptyUsername::new(candidate) {
            Ok(user) => println!("{}", greet_user(&user)),
            Err(err) => println!("Rejected {candidate:?}: {err:?}"),
        }
    }
}
Welcome, alice!
Rejected "": Empty
Rejected "username_that_is_way_too_long": TooLong { len: 29, max: 16 }

Error Formatting and Trait Conventions

Well-behaved Rust error types follow standard conventions across the ecosystem:

Convention Rule Reason
Display message style Lowercase start, concise, no trailing period ("invalid header syntax") Composes cleanly when chained ("failed to read request: invalid header syntax")
Error::source() vs. Display Return inner error from source() OR format it in Display, not both Prevents duplicate messages when error reporters walk the .source() chain
Thread safety bounds Ensure error types are Send + Sync + 'static Allows errors to cross thread boundaries and convert into Box<dyn Error + Send + Sync>
Type alias convenience Provide pub type Result<T> = std::result::Result<T, MyError>; (or Result<T, E = MyError>) Reduces signature boilerplate inside the module while allowing callers to override E
use std::error::Error;
use std::fmt;

#[non_exhaustive]
#[derive(Debug, PartialEq, Eq)]
pub enum AuthError {
    MissingToken,
    ExpiredToken { expired_at_epoch: u64 },
}

impl fmt::Display for AuthError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            // Lowercase start, no trailing period:
            AuthError::MissingToken => write!(f, "authentication token is missing"),
            AuthError::ExpiredToken { expired_at_epoch } => {
                write!(f, "authentication token expired at timestamp {expired_at_epoch}")
            }
        }
    }
}

impl Error for AuthError {}

pub type AuthResult<T, E = AuthError> = std::result::Result<T, E>;

fn verify_token(token: Option<&str>) -> AuthResult<&str> {
    let raw = token.ok_or(AuthError::MissingToken)?;
    if raw == "expired" {
        return Err(AuthError::ExpiredToken {
            expired_at_epoch: 1_700_000_000,
        });
    }
    Ok(raw)
}

fn main() {
    for sample in [Some("valid_jwt"), None, Some("expired")] {
        match verify_token(sample) {
            Ok(tok) => println!("Verified: {tok}"),
            Err(err) => println!("Auth failed: {err}"),
        }
    }
}
Verified: valid_jwt
Auth failed: authentication token is missing
Auth failed: authentication token expired at timestamp 1700000000