Error Handling in Rust: From unwrap to Advanced Techniques
Unlike languages with exceptions (Java, Python, C++), Rust does not use the try/catch mechanism. Instead, the language offers a powerful and safe type system for error handling. Beginner developers often overuse unwrap(), which leads to application panics. In this article, we will look at advanced techniques for working with errors in Rust: from creating custom error types to using the thiserror and anyhow crates, as well as how to emulate try/catch behavior.
1. Why did Rust abandon try/catch?
In languages with exceptions, the execution flow can be interrupted at any moment. This complicates code analysis and can lead to resource leaks. Rust offers two main approaches:
- Recoverable errors — the
Result<T, E>type (successOk(T)or errorErr(E)). - Unrecoverable errors — the
panic!macro, which stops the program.
The ? operator is Rust's equivalent of try. It extracts the value from Ok or immediately returns the Err from the function.
use std::fs::File;use std::io::Read;
fn read_username(path: &str) -> Result<String, std::io::Error> { let mut file = File::open(path)?; // if error — return it let mut username = String::new(); file.read_to_string(&mut username)?; // again "try" Ok(username.trim().to_string())}2. Creating Custom Error Types
For complex projects, a single std::io::Error is not enough. The best practice is to define your own error type using an enum. This allows grouping different kinds of errors and adding context.
use std::fmt;
#[derive(Debug)]enum MyAppError { IoError(std::io::Error), ParseError(String), NetworkError { url: String, status: u16 },}
impl fmt::Display for MyAppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { MyAppError::IoError(e) => write!(f, "IO error: {}", e), MyAppError::ParseError(s) => write!(f, "Parse error: {}", s), MyAppError::NetworkError { url, status } => { write!(f, "Network error for {}: status {}", url, status) } } }}
impl std::error::Error for MyAppError {} // implement the Error traitNow you can use ? for automatic conversion if you implement the From trait:
impl From<std::io::Error> for MyAppError { fn from(err: std::io::Error) -> MyAppError { MyAppError::IoError(err) }}
fn process_file(path: &str) -> Result<(), MyAppError> { let content = std::fs::read_to_string(path)?; // automatically converts if content.is_empty() { return Err(MyAppError::ParseError("empty file".into())); } Ok(())}3. Advanced Crates: thiserror and anyhow
Writing all the Display and From implementations manually is tedious. For this, there are two popular crates:
thiserror — for libraries
The thiserror crate generates the std::error::Error implementation via a derive macro. It is ideal for libraries where precise error descriptions are needed.
use thiserror::Error;
#[derive(Error, Debug)]pub enum DataError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Invalid UTF-8: {0}")] Utf8(#[from] std::str::Utf8Error), #[error("Custom failure: {details}")] Custom { details: String },}
// Now you can write:fn parse_data(bytes: &[u8]) -> Result<String, Da