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