Series: Learn Rust from Scratch | Post 7 of 11
Introduction
Every real program encounters errors: files that don't exist, invalid user input, network failures, bad data. Most languages deal with this through exceptions or by returning null — both of which can be easy to ignore accidentally.
Rust takes a different approach. It makes errors explicit in the type system. If a function can fail, its return type says so. You can't accidentally ignore an error — the compiler won't let you.
There are two categories of errors in Rust:
| Type | What It Means | Rust's Response |
|---|---|---|
| Unrecoverable | A bug — something that should never happen | panic! — crash immediately |
| Recoverable | Expected failure that programs should handle | Result<T, E> — handle explicitly |
panic! — Unrecoverable Errors
panic! immediately stops the program with an error message. Use it for bugs, not expected failures.
fn main() {
// Explicit panic
// panic!("Something went terribly wrong!");
// Index out of bounds — causes a panic automatically
let v = vec![1, 2, 3];
// v[10]; // This would panic: index out of bounds
// Division by zero with integers also panics
// let x = 10 / 0;
println!("Program ran fine.");
}
When a panic happens, you see output like:
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10', src/main.rs:7:5
Rule of thumb: Use
panic!for programming errors (bugs). UseResultfor errors that can reasonably happen at runtime.
Result<T, E> — Recoverable Errors
Result is an enum with two variants:
enum Result<T, E> {
Ok(T), // Success — contains a value of type T
Err(E), // Failure — contains an error of type E
}
Any function that can fail should return a Result:
use std::num::ParseIntError;
// This function can fail — it returns a Result
fn parse_age(s: &str) -> Result<u32, ParseIntError> {
s.trim().parse::<u32>()
}
fn main() {
// Handle with match
match parse_age("25") {
Ok(age) => println!("Parsed age: {}", age),
Err(err) => println!("Parse error: {}", err),
}
match parse_age("abc") {
Ok(age) => println!("Parsed age: {}", age),
Err(err) => println!("Parse error: {}", err),
}
}
Output:
Parsed age: 25
Parse error: invalid digit found in string
Useful Result Methods
fn main() {
let good: Result<i32, &str> = Ok(42);
let bad: Result<i32, &str> = Err("something failed");
// unwrap_or: get value or a default
println!("{}", good.unwrap_or(0)); // 42
println!("{}", bad.unwrap_or(0)); // 0
// unwrap_or_else: compute a default with a closure
println!("{}", bad.unwrap_or_else(|e| {
println!("Error was: {}", e);
-1
}));
// is_ok / is_err
println!("good is ok? {}", good.is_ok()); // true
println!("bad is err? {}", bad.is_err()); // true
// map: transform the Ok value
let doubled = good.map(|v| v * 2);
println!("{:?}", doubled); // Ok(84)
// map_err: transform the Err value
let new_err = bad.map_err(|e| format!("Error: {}", e));
println!("{:?}", new_err); // Err("Error: something failed")
}
Output:
42
0
Error was: something failed
-1
good is ok? true
bad is err? true
Ok(84)
Err("Error: something failed")
The ? Operator — Elegant Error Propagation
The ? operator is Rust's most ergonomic error handling feature. It:
- If the result is
Ok(v), extractsvand continues - If the result is
Err(e), returns the error from the current function immediately
use std::fs;
use std::io;
// Without ?: requires nested match
fn read_file_v1(path: &str) -> Result<String, io::Error> {
let content = match fs::read_to_string(path) {
Ok(s) => s,
Err(err) => return Err(err),
};
Ok(content)
}
// With ?: clean and concise
fn read_file_v2(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path)?; // If Err, return immediately
Ok(content)
}
// ? can be chained
fn read_and_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?; // Propagate IO error
let number: i32 = content.trim().parse()?; // Propagate parse error
Ok(number)
}
fn main() {
match read_file_v2("hello.txt") {
Ok(content) => println!("File content: {}", content),
Err(err) => println!("Could not read file: {}", err),
}
}
Important: The
?operator can only be used in functions that returnResult(orOption). It cannot be used inmain()unless you change its signature.
Using ? in main
use std::fs;
// main can return a Result
fn main() -> Result<(), Box<dyn std::error::Error>> {
let content = fs::read_to_string("data.txt")?;
println!("Content: {}", content);
Ok(())
}
Defining Custom Error Types
For real applications, define your own error types:
use std::fmt;
// Custom error enum
#[derive(Debug)]
enum AppError {
InvalidInput(String),
DivisionByZero,
NegativeNumber(f64),
OutOfRange { value: i32, min: i32, max: i32 },
}
// Implement Display so we can print the error
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
AppError::DivisionByZero => write!(f, "Cannot divide by zero"),
AppError::NegativeNumber(n) => write!(f, "Expected positive number, got {}", n),
AppError::OutOfRange { value, min, max } => {
write!(f, "Value {} is out of range [{}, {}]", value, min, max)
}
}
}
}
fn safe_divide(a: f64, b: f64) -> Result<f64, AppError> {
if b == 0.0 {
Err(AppError::DivisionByZero)
} else {
Ok(a / b)
}
}
fn square_root(n: f64) -> Result<f64, AppError> {
if n < 0.0 {
Err(AppError::NegativeNumber(n))
} else {
Ok(n.sqrt())
}
}
fn clamp(value: i32, min: i32, max: i32) -> Result<i32, AppError> {
if value < min || value > max {
Err(AppError::OutOfRange { value, min, max })
} else {
Ok(value)
}
}
fn main() {
// Test each error variant
println!("{:?}", safe_divide(10.0, 2.0));
println!("{:?}", safe_divide(10.0, 0.0));
println!("{:?}", square_root(9.0));
println!("{:?}", square_root(-4.0));
println!("{:?}", clamp(50, 0, 100));
println!("{:?}", clamp(150, 0, 100));
// Display format
if let Err(e) = clamp(150, 0, 100) {
println!("Error: {}", e);
}
}
Output:
Ok(5.0)
Err(DivisionByZero)
Ok(3.0)
Err(NegativeNumber(-4.0))
Ok(50)
Err(OutOfRange { value: 150, min: 0, max: 100 })
Error: Value 150 is out of range [0, 100]
Converting Between Error Types
In real programs, functions often call other functions that have different error types. Box<dyn Error> is a convenient way to handle multiple error types:
use std::num::ParseIntError;
use std::fmt;
#[derive(Debug)]
enum MyError {
Parse(ParseIntError),
TooLarge(i32),
}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::Parse(e) => write!(f, "Parse error: {}", e),
MyError::TooLarge(n) => write!(f, "Number {} is too large (max 100)", n),
}
}
}
// Implement From to allow `?` to auto-convert ParseIntError into MyError
impl From<ParseIntError> for MyError {
fn from(e: ParseIntError) -> MyError {
MyError::Parse(e)
}
}
fn parse_small_number(s: &str) -> Result<i32, MyError> {
let n: i32 = s.trim().parse()?; // ? converts ParseIntError → MyError via From
if n > 100 {
return Err(MyError::TooLarge(n));
}
Ok(n)
}
fn main() {
let inputs = ["42", "abc", "999", "7"];
for input in inputs {
match parse_small_number(input) {
Ok(n) => println!("'{}' → {}", input, n),
Err(e) => println!("'{}' → Error: {}", input, e),
}
}
}
Output:
'42' → 42
'abc' → Error: Parse error: invalid digit found in string
'999' → Error: Number 999 is too large (max 100)
'7' → 7
unwrap and expect — When You're Sure It Won't Fail
Sometimes you know a Result or Option will definitely be Ok/Some. Use unwrap or expect for these cases:
fn main() {
// unwrap: panics if Err with a generic message
let value: i32 = "42".parse().unwrap();
println!("{}", value);
// expect: panics with YOUR message if Err — much more helpful
let port: u16 = "8080".parse().expect("PORT must be a valid number");
println!("Server running on port {}", port);
// Use expect during prototyping — replace with proper handling in production
}
Best practice: Never use
unwrap()in production code. Useexpect()with a meaningful message during development, and replace with propermatch/?in production.
A Complete Error Handling Example
use std::fmt;
use std::collections::HashMap;
#[derive(Debug)]
enum StudentError {
NotFound(String),
InvalidGrade(f64),
}
impl fmt::Display for StudentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StudentError::NotFound(name) => write!(f, "Student '{}' not found", name),
StudentError::InvalidGrade(g) => write!(f, "Grade {:.1} is invalid (must be 0-100)", g),
}
}
}
struct Gradebook {
grades: HashMap<String, f64>,
}
impl Gradebook {
fn new() -> Self {
Gradebook { grades: HashMap::new() }
}
fn add_grade(&mut self, name: &str, grade: f64) -> Result<(), StudentError> {
if grade < 0.0 || grade > 100.0 {
return Err(StudentError::InvalidGrade(grade));
}
self.grades.insert(name.to_string(), grade);
Ok(())
}
fn get_grade(&self, name: &str) -> Result<f64, StudentError> {
self.grades
.get(name)
.copied()
.ok_or_else(|| StudentError::NotFound(name.to_string()))
}
fn letter_grade(&self, name: &str) -> Result<&str, StudentError> {
let grade = self.get_grade(name)?; // ? propagates the error
Ok(match grade as u32 {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
60..=69 => "D",
_ => "F",
})
}
}
fn main() {
let mut book = Gradebook::new();
let entries = [("Alice", 92.0), ("Bob", 75.5), ("Carol", 108.0), ("Dave", 61.0)];
for (name, grade) in entries {
match book.add_grade(name, grade) {
Ok(()) => println!("Added {} with grade {:.1}", name, grade),
Err(e) => println!("Error adding {}: {}", name, e),
}
}
println!();
let names = ["Alice", "Bob", "Carol", "Eve"];
for name in names {
match book.letter_grade(name) {
Ok(letter) => println!("{} → {}", name, letter),
Err(e) => println!("{} → Error: {}", name, e),
}
}
}
Output:
Added Alice with grade 92.0
Added Bob with grade 75.5
Error adding Carol: Grade 108.0 is invalid (must be 0-100)
Added Dave with grade 61.0
Alice → A
Bob → C
Carol → Error: Student 'Carol' not found
Eve → Error: Student 'Eve' not found
Summary
In this post you learned:
- ✅
panic!for unrecoverable errors (bugs);Result<T, E>for recoverable errors - ✅
Result<T, E>has two variants:Ok(value)andErr(error) - ✅ Useful
Resultmethods:unwrap_or,map,is_ok,is_err - ✅ The
?operator propagates errors cleanly — use it instead of nestedmatch - ✅ Custom error types with
enum+impl Display - ✅
Fromtrait enables automatic error conversion with? - ✅ Use
expect()overunwrap()to provide helpful panic messages
What's Next?
In Post 8, we'll explore Collections — Rust's built-in data structures including Vec<T>, HashMap<K, V>, and HashSet<T>, which you'll use constantly in real programs.
Next Post: Collections in Rust — Vec, HashMap, and HashSet →
Part of the Learn Rust from Scratch series on CodeWithNeo


Leave a Reply