Error Handling with ? & Custom Errors
In the last lesson you handled every Result with match. That works, but a function that calls five operations which can each fail would need five match blocks, and the real logic would drown in error handling. Rust's answer is the ? operator, which passes an error up to the caller in a single character.
In this lesson you will use ? with Result and Option, let main return errors, design your own error type that callers can match on, and learn when a panic is the right choice.
Panic or Result?
Rust separates two kinds of failure:
- Unrecoverable problems are bugs: an index that should never be out of range, a state your program logic says is impossible. Call
panic!("message")(or useunwrap/expect); the program stops and prints where it happened. - Recoverable problems are things that can go wrong in normal use: a user types letters into a number field, a file is missing, the network is down. Return a
Resultso the caller can decide what to do.
As a rule, library code should almost never panic on bad input. Return a Result and let the application choose: retry, ask the user again or show a message.
The ? operator
Put ? after an expression that returns a Result. If it is Ok(value), ? unwraps it and the code continues with value. If it is Err(e), the function returns immediately with that error.
use std::num::ParseIntError;
fn total_marks(theory: &str, practical: &str) -> Result<u32, ParseIntError> {
let t: u32 = theory.trim().parse()?;
let p: u32 = practical.trim().parse()?;
Ok(t + p)
}
fn main() {
println!("{:?}", total_marks("45", " 38 "));
println!("{:?}", total_marks("45", "3x"));
}Ok(83)
Err(ParseIntError { kind: InvalidDigit })Without ?, each parse would need its own match with an Err(e) => return Err(e) arm; ? writes exactly that for you. Two conditions apply: ? can only be used inside a function that itself returns Result (or Option), and the error must be convertible into the function's error type (more on that below).
? works on Option too, in functions returning Option: on None it returns None at once.
fn initials(full_name: &str) -> Option<String> {
let mut words = full_name.split_whitespace();
let first = words.next()?.chars().next()?;
let last = words.next()?.chars().next()?;
Some(format!("{first}.{last}."))
}
fn main() {
println!("{:?}", initials("Priya Sharma"));
println!("{:?}", initials("Priya"));
}Some("P.S.")
NoneFor "Priya" the second words.next() is None, so the function stops there and returns None.
Building your own error type
A String error is easy to create but hard to work with: callers cannot tell a "bad number" from an "out of stock" problem without comparing text. A custom error enum gives each failure its own variant. To be a good citizen, an error type should implement three traits (you will study traits properly in Traits):
Debug(derived), so it can be printed with{:?};Display, a user-friendly message for{};std::error::Error, the standard marker for error types.
Implementing From<OtherError> lets ? convert other errors into yours automatically.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum OrderError {
BadQuantity(ParseIntError),
OutOfStock { item: String, available: u32 },
EmptyOrder,
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OrderError::BadQuantity(e) => write!(f, "quantity is not a number ({e})"),
OrderError::OutOfStock { item, available } => {
write!(f, "only {available} {item} left in stock")
}
OrderError::EmptyOrder => write!(f, "order has no items"),
}
}
}
impl std::error::Error for OrderError {}
impl From<ParseIntError> for OrderError {
fn from(e: ParseIntError) -> Self {
OrderError::BadQuantity(e)
}
}
fn place_order(item: &str, qty_text: &str, stock: u32) -> Result<u32, OrderError> {
let qty: u32 = qty_text.trim().parse()?; // ParseIntError becomes OrderError
if qty == 0 {
return Err(OrderError::EmptyOrder);
}
if qty > stock {
return Err(OrderError::OutOfStock { item: item.to_string(), available: stock });
}
Ok(stock - qty)
}
fn main() {
let orders = [("samosa", "12"), ("jalebi", "40"), ("lassi", "two"), ("chai", "0")];
for (item, qty) in orders {
match place_order(item, qty, 30) {
Ok(left) => println!("{item}: order placed, {left} left"),
Err(e) => println!("{item}: failed, {e}"),
}
}
}samosa: order placed, 18 left jalebi: failed, only 30 jalebi left in stock lassi: failed, quantity is not a number (invalid digit found in string) chai: failed, order has no items
write! works like format! but writes into the formatter f. The <'_> after Formatter is a lifetime marker, which you can ignore until the Lifetimes lesson. The empty impl std::error::Error for OrderError {} is enough, because the trait's methods all have default versions. A caller can now match on OrderError::OutOfStock { available, .. } and, for example, offer the available quantity instead.
Returning errors from main
main may return Result<(), E>. If it returns Err, Rust prints Error: followed by the error's Debug form and exits with a non-zero exit code, which scripts and CI servers understand as failure. The type Box<dyn Error> means "any error type", and ? converts every standard error into it:
use std::error::Error;
// OrderError and place_order as above
fn main() -> Result<(), Box<dyn Error>> {
let left = place_order("samosa", "12", 30)?;
println!("Stock left: {left}");
let left = place_order("lassi", "two", 30)?;
println!("Stock left: {left}");
Ok(())
}Stock left: 18
Error: BadQuantity(ParseIntError { kind: InvalidDigit })This is convenient in small programs. Applications usually print a friendlier message themselves, or use the anyhow crate, which you will meet in Popular Crates: serde, clap & anyhow.
Less typing with thiserror
Writing Display and From by hand gets repetitive. The widely used thiserror crate (version 2, add it with cargo add thiserror) generates them from attributes. This is the same OrderError as above:
use thiserror::Error;
#[derive(Debug, Error)]
enum OrderError {
#[error("quantity is not a number ({0})")]
BadQuantity(#[from] std::num::ParseIntError),
#[error("only {available} {item} left in stock")]
OutOfStock { item: String, available: u32 },
#[error("order has no items")]
EmptyOrder,
}#[error("...")] becomes the Display text, where {0} is the first unnamed field and {item} a named one, and #[from] writes the From conversion. A common split: libraries define precise errors with thiserror, and applications collect them with anyhow.
Common mistakes
- Using
?in a function that returns nothing.?needs aResultorOptionreturn type. Change the signature, for example to-> Result<(), Box<dyn Error>>. - Mismatched error types. If
?cannot convert an error, the compiler says the traitFrom<...>is not implemented. Add aFromimpl, usemap_err, or useBox<dyn Error>. - Using
Stringfor every error. Callers cannot react to specific failures. Use an enum for errors that callers may want to handle differently. - Panicking in library code on bad input. Return a
Resultand let the caller decide.
Key points
- Panic for bugs and impossible states; return
Resultfor failures that can happen in normal use. expr?unwrapsOk/Someor returns theErr/Nonefrom the current function immediately.?converts errors usingFrom, so implementingFrom<OtherError>for your error type makes conversion automatic.- A good custom error is an enum that derives
Debugand implementsDisplayandstd::error::Error;thiserrorgenerates this code. maincan returnResult<(), Box<dyn Error>>; anErrprintsError: ...and exits with a failure code.
Exercise
Write fn parse_time(text: &str) -> Result<(u32, u32), TimeError> for a train timetable that turns "14:35" into (14, 35). Create an enum TimeError with variants MissingColon, BadNumber(ParseIntError) and OutOfRange, implement Display, std::error::Error and From<ParseIntError>, and use ? for the number parsing. Test it with "14:35", "1435", "ab:10" and "25:00".
Show solution
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum TimeError {
MissingColon,
BadNumber(ParseIntError),
OutOfRange,
}
impl fmt::Display for TimeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TimeError::MissingColon => write!(f, "expected HH:MM"),
TimeError::BadNumber(e) => write!(f, "bad number: {e}"),
TimeError::OutOfRange => write!(f, "hour or minute out of range"),
}
}
}
impl std::error::Error for TimeError {}
impl From<ParseIntError> for TimeError {
fn from(e: ParseIntError) -> Self {
TimeError::BadNumber(e)
}
}
fn parse_time(text: &str) -> Result<(u32, u32), TimeError> {
let Some((h, m)) = text.split_once(':') else {
return Err(TimeError::MissingColon);
};
let hour: u32 = h.parse()?;
let minute: u32 = m.parse()?;
if hour > 23 || minute > 59 {
return Err(TimeError::OutOfRange);
}
Ok((hour, minute))
}
fn main() {
for t in ["14:35", "1435", "ab:10", "25:00"] {
match parse_time(t) {
Ok((h, m)) => println!("{t} -> {h} hours {m} minutes"),
Err(e) => println!("{t} -> error: {e}"),
}
}
}14:35 -> 14 hours 35 minutes 1435 -> error: expected HH:MM ab:10 -> error: bad number: invalid digit found in string 25:00 -> error: hour or minute out of range
split_once(':') returns Some((before, after)) or None, which let ... else turns into the MissingColon error. The ? after each parse converts a ParseIntError into TimeError::BadNumber through the From impl.
Quick Quiz
1. What does let n: u32 = text.parse()?; do when parsing fails?
? propagates the Err to the caller, converting it with From if needed.
2. Where can you use the ? operator?
The function must return a type that can carry the early-return value.
3. Why implement From<ParseIntError> for your own error type?
? calls From::from on the error, so the conversion happens without extra code.
4. What does a program print when main returns Err(e)?
The standard library prints the error's Debug representation after Error: and reports failure.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
