8.3 Handling Errors

Error Handling - Recoverable and Unrecoverable Errors
Section titled “Error Handling - Recoverable and Unrecoverable Errors”Introduction: Explicit Error Handling
Section titled “Introduction: Explicit Error Handling”In C++, error handling often relies on return codes, global error states (like errno), or exceptions. These mechanisms can result in unhandled errors or unpredictable control flow.
Rust uses an explicit approach to error handling by integrating it into the type system. Error conditions are part of a function’s signature, requiring you to handle potential failures at compile time. This section examines Rust’s two categories of errors: unrecoverable and recoverable.
Unrecoverable Errors: panic!
Section titled “Unrecoverable Errors: panic!”An unrecoverable error indicates a problem from which the program cannot continue, such as a bug in logic. When such an error occurs, Rust will panic!.
The panic! Macro
The panic! macro terminates the program immediately. Rust unwinds the stack, cleans up resources, and exits. This is similar to a fatal error or an assertion failure in C++.
panic! should be used for situations where:
- The program has entered an inconsistent state due to a bug.
- A critical invariant has been violated.
- An operation has failed in a way that recovery is impossible or nonsensical.
Explicit panic! example: We can trigger a panic directly as follows:
fn main() { println!("Starting application..."); // Explicitly trigger a panic with a message panic!("Something went terribly wrong!"); // This line will never be reached println!("Application finished.");}This will give the following output:
Starting application...
thread 'main' panicked at src\main.rs:4:5:Something went terribly wrong!stack backtrace: 0: std::panicking::begin_panic_handler at /rustc/17067e9ac6d7ecb70e50f92c1944e545188d2359/library\std\src\panicking.rs:697 1: core::panicking::panic_fmt at /rustc/17067e9ac6d7ecb70e50f92c1944e545188d2359/library\core\src\panicking.rs:75 2: hello_een1097::main at .\src\main.rs:4…Note that the final "Application finished." message will not be outputted as the println() will not be executed.
Implicit Panics (Runtime Checks)
Rust’s commitment to memory safety means that certain operations that would lead to undefined behaviour in C/C++ will instead cause a panic! at runtime in safe Rust code. A common example is out-of-bounds array access.
Out-of-Bounds Access (Implicit Panic)
This code will not compile as the compiler will detect that the numbers[3] call will cause a panic at runtime. However, if this was a dynamic variable (e.g., asking the user to choose a number between 0 and 2 inclusive) then the compiler could not know the value that the user enters in advance.
fn main() { let numbers = [1, 2, 3]; // Attempting to access an index beyond the array's bounds println!("Value at index 3: {}", numbers[3]); // This will panic at runtime}The Rust compiler often catches such obvious errors at compile time, but if the index is dynamic, it will result in a runtime panic. Rust’s runtime bounds checks, while potentially incurring a minor performance overhead if not optimised away, ensure spatial safety by preventing access to out-of-bounds memory.
Catching Panics (catch_unwind)
While panic! is primarily for unrecoverable errors, Rust does provide std::panic::catch_unwind to attempt to recover from a panic. However, this is generally discouraged for routine error handling and is mostly used in specific scenarios like building robust servers that need to keep running even if one thread panics. It’s not guaranteed that a panic can always be caught, as the binary can be configured to abort on panic.
Recoverable Errors: Result<T, E>
Section titled “Recoverable Errors: Result<T, E>”We have used this error type earlier in the notes. For errors that are expected and from which a program can potentially recover, Rust uses the Result<T, E> enum. This is Rust’s primary mechanism for explicit, type-safe error handling, replacing traditional error codes and exceptions.
The Result<T, E> Enum
The Result<T, E> enum has two variants:
Ok(T): Represents success, containing a value of typeT(the successful result).Err(E): Represents failure, containing a value of typeE(the error information).
This forces functions to explicitly declare that they might fail and what kind of error they might produce, making error handling a compile-time concern. A common scenario for recoverable errors is attempting to open a file that might not exist.
Example: File Opening with Result and match
use std::fs::File;use std::io::ErrorKind; // To match specific error types
fn main() { // This file won't exist on the first run let file_result = File::open("hello.txt");
// Use a match expression to handle the Result let greeting_file = match file_result { Ok(file) => { println!("File opened successfully: {:?}", file); file }, Err(error) => match error.kind() { // Nested match for specific error kinds ErrorKind::NotFound => { println!("File not found, attempting to create it..."); match File::create("hello.txt") { Ok(fc) => { println!("File created successfully: {:?}", fc); fc }, Err(e) => { // This is an unrecoverable error for this example panic!("Problem creating the file: {:?}", e); }, } }, other_error => { // All other I/O errors panic!("Problem opening the file: {:?}", other_error); }, }, }; // The greeting_file variable now holds a valid File handle // at this point or the program would have panicked. println!("File handle is now in use: {:?}", greeting_file);}The first time it is run you will see the output:
File not found, attempting to create it…File created successfully: File { handle: 0xc8, path: "\\\\?\\C:\\Temp\\hello_een1097\\hello.txt" }File handle is now in use: File { handle: 0xc8, path: "\\\\?\\C:\\Temp\\hello_een1097\\hello.txt" }The second time it is run you will see the output:
File opened successfully: File { handle: 0x4c, path: "\\\\?\\C:\\Temp\\hello_een1097\\hello.txt" }File handle is now in use: File { handle: 0x4c, path: "\\\\?\\C:\\Temp\\hello_een1097\\hello.txt" }In this example, File::open returns a Result<File, std::io::Error>. The match statement explicitly handles both Ok and Err cases. Within the Err case, a nested match can differentiate between specific ErrorKind variants, such as NotFound.
Modern C++ (C++23) introduces std::expected<T, E>, which is conceptually very similar to Rust’s Result<T, E>, providing a type-safe way to represent either a value or an error.
Propagating Errors with ? Operator
Handling nested match statements for Result types can become verbose, as can be seen in the previous example. However, Rust provides the ? operator as a concise way to propagate errors up the call stack. The ? operator can only be used in functions that return a Result or Option.
If a Result is Ok, then the ? operator unwraps the value and the execution continues. If it’s Err, the Err value is immediately returned from the current function. So, for example to use Error Propagation with ?
use std::fs::File;use std::io::{self, Read}; // Import io for io::Error
// This function attempts to read a username from a filefn read_username_from_file() -> Result<String, io::Error> { let mut f = File::open("username.txt")?; // If Err, returns Err from this function let mut s = String::new(); f.read_to_string(&mut s)?; // If Err, returns Err from this function Ok(s) // If all Ok, return the username}
fn main() { match read_username_from_file() { Ok(username) => println!("Username: {}", username), Err(e) => println!("Error reading username: {}", e), }}This code gives the following output based on the error being returned by the read_username_from_file() function:
Error reading username: The system cannot find the file specified. (os error 2)This is significantly more concise than explicit match statements for each potential error.
unwrap() and expect()
For situations where you are confident that an operation should not fail, or if failure indicates a bug, Result provides unwrap() and expect() methods. These methods are shortcuts that will return the Ok value if successful, but will panic! if the Result is Err.
unwrap(): Panics with a default error message.expect(message): Panics with a custom error message.
use std::fs::File;
fn main() { // Using unwrap() - will panic if file not found let greeting_file1 = File::open("existing_file.txt").unwrap(); println!("File 1 opened: {:?}", greeting_file1);
// Using expect() - will panic with custom message if file not found let greeting_file2 = File::open("non_existent_file.txt") .expect("Failed to open non_existent_file.txt"); println!("File 2 opened: {:?}", greeting_file2); // This line will not be reached}It is straightforward if the file exists, but this will give the output:
thread 'main' panicked at src\main.rs:5:58:called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "The system cannot find the file specified." }stack backtrace: 0: std::panicking::begin_panic_handler…These methods are useful for prototyping or when you’ve already handled the error in a way that guarantees success (e.g., by creating the file if it doesn’t exist). However, relying on them too heavily can turn recoverable errors into unrecoverable panics, similar to unhandled exceptions in C++.
Optional Values: Option<T>
Section titled “Optional Values: Option<T>”Beyond explicit errors, Rust also provides Option<T> for scenarios where a value might simply be absent. This is distinct from an error, as the absence of a value might be a perfectly valid and expected outcome.
The Option<T> Enum
The Option<T> enum has two variants:
Some(T): The value is present, containing a value of typeT.None: The value is absent.
Rust does not have null pointers. Instead, Option<T> is used to represent the possibility of a value being null or absent, forcing explicit handling of this possibility. For example, here is an example of an Option for array element access, where accessing elements in a Vec or array by index can return an Option to safely handle out-of-bounds access.
fn main() { let numbers = vec![4, 5, 6];
// Accessing a valid index returns Some(value) let first_number = numbers.get(0); match first_number { Some(num) => println!("First number: {}", num), // Output: First number: 4 None => println!("No first number found."), }
// Accessing an invalid index returns None let fourth_number = numbers.get(3); match fourth_number { Some(num) => println!("Fourth number: {}", num), None => println!("No fourth number found."), // Output: No fourth number found. }}This will give the output:
First number: 4No fourth number found.Which is a very clean way of handling an out-of-bounds problem.
Custom Error Types
Section titled “Custom Error Types”While std::io::Error and other built-in error types are useful, real-world applications often benefit from custom error types that precisely define specific failure scenarios within a domain. Rust encourages defining custom error types, typically as enums, to provide more specific and user-friendly error messages.
Defining a Simple Custom Error Enum
A common pattern is to define an enum where each variant represents a distinct error condition. For example, to define a custom error type:
use std::fmt; // Required for Display trait
// Define a custom error enum// Allows for {:?} printing#[derive(Debug)]enum MyCustomError { InvalidInput(String), ResourceNotFound, PermissionDenied, NetworkError(u16), // Can hold data, e.g., HTTP status code}
// Implement the Display trait for user-friendly messagesimpl fmt::Display for MyCustomError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { // error text shortened for clarity in this example MyCustomError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), MyCustomError::ResourceNotFound => write!(f, "Requested resource not found."), MyCustomError::PermissionDenied => write!(f, "Access denied: Permissions."), MyCustomError::NetworkError(code) => write!(f, "Net op fail code: {}", code), } }}
// Implement the std::error::Error trait (often done for custom errors)impl std::error::Error for MyCustomError {}
// A function that might return our custom errorfn perform_operation(value: i32) -> Result<String, MyCustomError> { if value < 0 { Err(MyCustomError::InvalidInput(format!("Value cannot be negative: {}", value))) } else if value == 0 { Err(MyCustomError::ResourceNotFound) } else if value == 1 { Err(MyCustomError::PermissionDenied) } else if value == 2 { Err(MyCustomError::NetworkError(503)) } else { Ok(format!("Operation successful with value: {}", value)) }}
fn main() { // Example usage of the custom error match perform_operation(-5) { Ok(s) => println!("{}", s), Err(e) => println!("Operation failed: {}", e), // Uses Display impl }
match perform_operation(0) { Ok(s) => println!("{}", s), Err(e) => println!("Operation failed: {}", e), }
match perform_operation(10) { Ok(s) => println!("{}", s), Err(e) => println!("Operation failed: {}", e), }}This example code will give the following output:
Operation failed: Invalid input: Value cannot be negative: -5Operation failed: Requested resource not found.Operation successful with value: 10This approach provides specificity (clearly defined error scenarios), type safety (compiler-enforced error handling), and expressiveness (meaningful error messages).
In real-world Rust development, crates like thiserror (for libraries) and anyhow (for applications) are widely used to simplify the creation and management of custom error types.
Rust’s error handling uses the panic! macro for unrecoverable bugs and the Result<T, E> and Option<T> enums for recoverable conditions. This differs from C++‘s traditional approaches. By embedding error information into function signatures and using pattern matching, Rust requires you to consider potential failure paths at compile time.
For C++ engineers, this is a shift from reactive debugging of runtime errors to proactive design for correctness. While Result and Option have a learning curve, they result in fewer runtime bugs and more predictable applications. This is important for domains like edge computing and embedded systems where reliability is required. Rust’s error handling and memory safety allows you to build resilient software.
🎬Code Demo: Error Handling
Section titled “🎬Code Demo: Error Handling”Idiomatic Error Handling in Rust
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Error Handling Concepts
When should the panic! macro be used in a production-grade edge application?
What is the requirement for using the '?' operator in a function?
How does Rust's Option<T> improve code reliability compared to C++'s nullptr?
What happens during 'stack unwinding' when a panic occurs?
Which crate is commonly used in Rust libraries to simplify the creation of custom error types?
© 2026 Derek Molloy, Dublin City University. All rights reserved.