What you'll learn: How Rust replaces null pointers with
Option<T>and exceptions withResult<T, E>, and how the?operator makes error propagation concise. This is Rust's most distinctive pattern — errors are values, not hidden control flow.
enum type we learned earlier? Rust's Option and Result are simply enums defined in the standard library:// This is literally how Option is defined in std:
enum Option<T> {
Some(T), // Contains a value
None, // No value
}
// And Result:
enum Result<T, E> {
Ok(T), // Success with value
Err(E), // Error with details
}
match works directly with Option and ResultOption<T> is the replacement, and the compiler forces you to handle the None case| C++ Pattern | Rust Equivalent | Advantage |
|---|---|---|
throw std::runtime_error(msg) | Err(MyError::Runtime(msg)) | Error in return type — can't forget to handle |
try { } catch (...) { } | match result { Ok(v) => ..., Err(e) => ... } | No hidden control flow |
std::optional<T> | Option<T> | Exhaustive match required — can't forget None |
noexcept annotation | Default — all Rust functions are "noexcept" | Exceptions don't exist |
errno / return codes | Result<T, E> | Type-safe, can't ignore |
Option type is an enum with only two variants: Some<T> and None
nullable type, i.e., it either contains a valid value of that type (Some<T>), or has no valid value (None)Option type is used in APIs where the result of an operation either succeeds and returns a valid value or it fails (but the specific error is irrelevant). For example, consider parsing a string for an integer valuefn main() {
// Returns Option<usize>
let a = "1234".find("1");
match a {
Some(a) => println!("Found 1 at index {a}"),
None => println!("Couldn't find 1")
}
}
Option can be processed in various ways
unwrap() panics if the Option<T> is None and returns T otherwise and it is the least preferred approachor() can be used to return an alternative valueif let lets us test for Some<T>Production patterns: See Safe value extraction with unwrap_or and Functional transforms: map, map_err, find_map for real-world examples from production Rust code.
fn main() {
// This return an Option<usize>
let a = "1234".find("1");
println!("{a:?} {}", a.unwrap());
let a = "1234".find("5").or(Some(42));
println!("{a:?}");
if let Some(a) = "1234".find("1") {
println!("{a}");
} else {
println!("Not found in string");
}
// This will panic
// "1234".find("5").unwrap();
}
enum type similar to Option with two variants: Ok<T> or Err<E>
Result is used extensively in Rust APIs that can fail. The idea is that on success, functions will return a Ok<T>, or they will return a specific error Err<T> use std::num::ParseIntError;
fn main() {
let a : Result<i32, ParseIntError> = "1234z".parse();
match a {
Ok(n) => println!("Parsed {n}"),
Err(e) => println!("Parsing failed {e:?}"),
}
let a : Result<i32, ParseIntError> = "1234z".parse().or(Ok(-1));
println!("{a:?}");
if let Ok(a) = "1234".parse::<i32>() {
println!("Let OK {a}");
}
// This will panic
//"1234z".parse().unwrap();
}
Option and Result are deeply related — Option<T> is essentially Result<T, ()> (a result where the error carries no information):
Option<T> | Result<T, E> | Meaning |
|---|---|---|
Some(value) | Ok(value) | Success — value is present |
None | Err(error) | Failure — no value (Option) or error details (Result) |
Converting between them:
fn main() {
let opt: Option<i32> = Some(42);
let res: Result<i32, &str> = opt.ok_or("value was None"); // Option → Result
let res: Result<i32, &str> = Ok(42);
let opt: Option<i32> = res.ok(); // Result → Option (discards error)
// They share many of the same methods:
// .map(), .and_then(), .unwrap_or(), .unwrap_or_else(), .is_some()/is_ok()
}
Rule of thumb: Use
Optionwhen absence is normal (e.g., looking up a key). UseResultwhen failure needs explanation (e.g., file I/O, parsing).
🟢 Starter
log() function that accepts an Option<&str> parameter. If the parameter is None, it should print a default stringResult with () for both success and error (in this case we'll never have an error)fn log(message: Option<&str>) -> Result<(), ()> {
match message {
Some(msg) => println!("LOG: {msg}"),
None => println!("LOG: (no message provided)"),
}
Ok(())
}
fn main() {
let _ = log(Some("System initialized"));
let _ = log(None);
// Alternative using unwrap_or:
let msg: Option<&str> = None;
println!("LOG: {}", msg.unwrap_or("(default message)"));
}
// Output:
// LOG: System initialized
// LOG: (no message provided)
// LOG: (default message)
panics should be avoided. panics are caused by bugs in the program, including exceeding index bounds, calling unwrap() on an Option<None>, etc.panics for conditions that should be impossible. The panic! or assert! macros can be used for sanity checksfn main() {
let x : Option<u32> = None;
// println!("{x}", x.unwrap()); // Will panic
println!("{}", x.unwrap_or(0)); // OK -- prints 0
let x = 41;
//assert!(x == 42); // Will panic
//panic!("Something went wrong"); // Unconditional panic
let _a = vec![0, 1];
// println!("{}", a[2]); // Out of bounds panic; use a.get(2) which will return Option<T>
}
// C++ error handling - exceptions create hidden control flow
#include <fstream>
#include <stdexcept>
std::string read_config(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
throw std::runtime_error("Cannot open: " + path);
}
std::string content;
// What if getline throws? Is file properly closed?
// With RAII yes, but what about other resources?
std::getline(file, content);
return content; // What if caller doesn't try/catch?
}
int main() {
// ERROR: Forgot to wrap in try/catch!
auto config = read_config("nonexistent.txt");
// Exception propagates silently, program crashes
// Nothing in the function signature warned us
return 0;
}
Result<T, E> Visualization// Rust error handling - comprehensive and forced
use std::fs::File;
use std::io::Read;
fn read_file_content(filename: &str) -> Result<String, std::io::Error> {
let mut file = File::open(filename)?; // ? automatically propagates errors
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents) // Success case
}
fn main() {
match read_file_content("example.txt") {
Ok(content) => println!("File content: {}", content),
Err(error) => println!("Failed to read file: {}", error),
// Compiler forces us to handle both cases!
}
}
enum Result<T, E> enum for recoverable error handling
Ok<T> variant contains the result in case of success and Err<E> contains the errorfn main() {
let x = "1234x".parse::<u32>();
match x {
Ok(x) => println!("Parsed number {x}"),
Err(e) => println!("Parsing error {e:?}"),
}
let x = "1234".parse::<u32>();
// Same as above, but with valid number
if let Ok(x) = &x {
println!("Parsed number {x}")
} else if let Err(e) = &x {
println!("Error: {e:?}");
}
}
? is a convenient short hand for the match Ok / Err pattern
Result<T, E> to enable use of ?Result<T, E> can be changed. In the example below, we return the same error type (std::num::ParseIntError) returned by str::parse()fn double_string_number(s : &str) -> Result<u32, std::num::ParseIntError> {
let x = s.parse::<u32>()?; // Returns immediately in case of an error
Ok(x*2)
}
fn main() {
let result = double_string_number("1234");
println!("{result:?}");
let result = double_string_number("1234x");
println!("{result:?}");
}
// Changes the error type to () in case of error
fn double_string_number(s : &str) -> Result<u32, ()> {
let x = s.parse::<u32>().map_err(|_|())?; // Returns immediately in case of an error
Ok(x*2)
}
fn double_string_number(s : &str) -> Result<u32, ()> {
let x = s.parse::<u32>().unwrap_or_default(); // Defaults to 0 in case of parse error
Ok(x*2)
}
fn double_optional_number(x : Option<u32>) -> Result<u32, ()> {
// ok_or converts Option<None> to Result<u32, ()> in the below
x.ok_or(()).map(|x|x*2) // .map() is applied only on Ok(u32)
}
🟡 Intermediate
log() function with a single u32 parameter. If the parameter is not 42, return an error. The Result<> for success and error type is ()log() function that exits with the same Result<> type if log() return an error. Otherwise print a message saying that log was successfully calledfn log(x: u32) -> ?? {
}
fn call_log(x: u32) -> ?? {
// Call log(x), then exit immediately if it return an error
println!("log was successfully called");
}
fn main() {
call_log(42);
call_log(43);
}
fn log(x: u32) -> Result<(), ()> {
if x == 42 {
Ok(())
} else {
Err(())
}
}
fn call_log(x: u32) -> Result<(), ()> {
log(x)?; // Exit immediately if log() returns an error
println!("log was successfully called with {x}");
Ok(())
}
fn main() {
let _ = call_log(42); // Prints: log was successfully called with 42
let _ = call_log(43); // Returns Err(()), nothing printed
}
// Output:
// log was successfully called with 42