Error Handling

Rust has no exceptions. Errors are values, handled through Option and Result types.

Most languages throw errors — you don't know if a function can fail until it blows up when running. Rust makes errors clear in the return type. If a function returns Result, you must handle the error case. No surprises. The compiler won't let you forget.

Option<T>

A value that may be present (Some) or absent (None).

Option is the "maybe there's something, maybe there isn't." Instead of returning null and hoping the caller checks, Rust wraps it in Option. You must unwrap it to get the value — the compiler forces you to handle None. No null pointer exceptions. Ever.
 1 │ enum Option<T> {
 2 │     Some(T),
 3 │     None,
 4 │ }
 5 │
 6 │ let x: Option<i32> = Some(5);
 7 │ let y: Option<i32> = None;
 8 │
 9 │ // Extract with match
10 │ match x {
11 │     Some(val) => println!("value: {val}"),
12 │     None => println!("no value"),
13 │ }
14 │
15 │ // if let (shorter)
16 │ if let Some(val) = x {
17 │     println!("{val}");
18 │ }
19 │
20 │ // Common methods
21 │ x.unwrap();          // panic if None
22 │ x.expect("msg");     // panic with message if None
23 │ x.is_some();         // bool
24 │ x.is_none();         // bool
25 │ x.map(|v| v * 2);    // Some(10) or None
26 │ x.and_then(|v| Some(v * 2));  // chain (flat_map)
27 │ x.unwrap_or(0);      // 5 or 0 if None
28 │ x.unwrap_or_else(|| 0); // lazy default
29 │ x.take();            // Some(5), leaves None in place
30 │ x.replace(10);       // replaces, returns old value
Legend: 1-4 Option is either Some(value) or None   6-7 create Optional values   10-13 match forces you to handle both cases   16-18 shorter form when you only care about Some   25 map transforms value inside Some

Result<T, E>

A value that may succeed (Ok(T)) or fail (Err(E)). T = the success value type, E = the error type.

Think of ordering food: you either get your meal (Ok(burger)) or a reason it failed (Err("out of stock")). You must check which one you got — the compiler won't let you eat the burger without checking if it's actually there.
 1 │ enum Result<T, E> {
 2 │     Ok(T),
 3 │     Err(E),
 4 │ }
 5 │
 6 │ fn divide(a: f64, b: f64) -> Result<f64, String> {
 7 │     if b == 0.0 {
 8 │         Err("division by zero".to_string())
 9 │     } else {
10 │         Ok(a / b)
11 │     }
12 │ }
13 │
14 │ match divide(10.0, 0.0) {
15 │     Ok(val) => println!("{val}"),
16 │     Err(msg) => println!("error: {msg}"),
17 │ }
18 │
19 │ // Common methods
20 │ divide(10.0, 2.0).unwrap();       // panic! on Err
21 │ divide(10.0, 2.0).expect("math"); // panic! with msg
22 │ divide(10.0, 2.0).is_ok();       // bool
23 │ divide(10.0, 2.0).is_err();      // bool
24 │ divide(10.0, 2.0).ok();          // Option<T>
25 │ divide(10.0, 2.0).err();         // Option<E>
26 │ divide(10.0, 2.0).map(|v| v * 2);
27 │ divide(10.0, 2.0).map_err(|e| format!("calc: {e}"));
28 │ divide(10.0, 2.0).unwrap_or(0.0);
29 │ divide(10.0, 2.0).unwrap_or_else(|_| 0.0);
Legend: 1-4 Result = Ok(value) or Err(reason)   6 return type says "can fail with String"   8 Err(...) for failure   10 Ok(...) for success   14-17 match forces both paths   20 unwrap() panics on Err

The ? Operator

"If this works, give me the value. If it fails, get me out of here." The ? operator unwraps Ok or returns the Err immediately — like passing a problem up to your boss.

 1 │ use std::fs::File;
 2 │ use std::io::{self, Read};
 3 │
 4 │ fn read_file(path: &str) -> Result<String, io::Error> {
 5 │     let mut f = File::open(path)?;  // if Err, return early
 6 │     let mut s = String::new();
 7 │     f.read_to_string(&mut s)?;      // if Err, return early
 8 │     Ok(s)
 9 │ }
10 │
11 │ // Works with Option too:
12 │ fn first_char(s: &str) -> Option<char> {
13 │     s.chars().next()?  // returns None if empty
14 │ }
Legend: 5 ? unwraps Ok or returns Err early   7 same pattern — early return on failure   8 wrap success in Ok   13 ? works with Option too

Key rule:

? only works inside functions that return Result or Option. If you try to use ? in fn main(), you'll get an error — either change main to return Result or handle the error by hand with match or .unwrap().

Chaining Methods

Chain operations without writing match. Think of them as a pipeline: take value, change it, pass it along.

 1 │ let result = divide(10.0, 2.0)
 2 │     .map(|v| v * 2.0)
 3 │     .map_err(|e| format!("error: {e}"))
 4 │     .and_then(|v| Ok(v + 1.0));
 5 │
 6 │ // and_then: like flat_map — return Result/Option from closure
 7 │ // or_else:  recover from Err (provide fallback)
 8 │
 9 │ // Recover from errors
10 │ let val = divide(10.0, 0.0)
11 │     .unwrap_or_else(|e| {
12 │         eprintln!("warning: {e}, using default");
13 │         0.0
14 │     });
Legend: 1-4 chain transformations like a pipeline   2 map transforms success value   3 map_err transforms error value   4 and_then returns a new Result   10-14 unwrap_or_else provides a fallback

Custom Error Types

You probably don't need this as a beginner. Use anyhow (below) instead. Custom error types are for libraries where users need to match on specific errors.
use std::fmt;

#[derive(Debug)]
struct MyError {
    details: String,
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.details)
    }
}

impl std::error::Error for MyError {}

// Use with ?
fn do_thing() -> Result<(), MyError> {
    return Err(MyError { details: "oops".into() });
}

// Convert other errors into your type
impl From<io::Error> for MyError {
    fn from(e: io::Error) -> Self {
        MyError { details: e.to_string() }
    }
}

// Now ? works with io::Error too
fn read() -> Result<(), MyError> {
    let _f = File::open("x.txt")?;  // io::Error → MyError
    Ok(())
}

Using anyhow & thiserror (Common Crates)

In real projects, use these crates instead of writing custom error types by hand.

anyhow — for application code

use anyhow::{Result, Context, bail};

fn read_config() -> Result<String> {
    let s = std::fs::read_to_string("config.toml")
        .context("failed to read config")?;  // attach context
    Ok(s)
}

fn main() -> Result<()> {
    let cfg = read_config()?;
    if cfg.is_empty() {
        bail!("config is empty");  // early error
    }
    Ok(())
}

thiserror — for library code

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("network error: {0}")]
    Network(#[from] io::Error),

    #[error("parse error at line {line}")]
    Parse { line: usize, source: String },

    #[error("unknown error")]
    Unknown,
}

// Automatically implements Display, Error, From

panic!

Your program hits a wall and can't go on. It prints a message, cleans up, and stops.

Using .unwrap() a lot while learning is totally fine. When you know a value can't fail, .unwrap() is acceptable. Replace with proper error handling when you're ready. The important thing is: unwrap on None/Err will panic! — so don't use it where failure is possible.
panic!("something went wrong");

// Also:
unimplemented!();  // "not yet implemented"
todo!();           // "to-do"
unreachable!();    // "this code should never run"
assert!(x == 5);
assert_eq!(x, 5);
assert_ne!(x, 0);

Prefer Result over panic! in library code. panic! is acceptable in tests, examples, and unrecoverable states.

Try It Yourself

Task 1: Safe Division

Write a function that divides two numbers. Return Result<f64, String> — Err if dividing by zero.

fn safe_div(a: f64, b: f64) -> Result {
    // your code
}

fn main() {
    match safe_div(10.0, 0.0) {
        Ok(r) => println!("{r}"),
        Err(e) => println!("Error: {e}"),
    }
}
Show solution
fn safe_div(a: f64, b: f64) -> Result {
    if b == 0.0 {
        Err("cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

Task 2: Parse Numbers

Parse a line of comma-separated numbers. Return Vec<i32>, skipping invalid values (use filter_map).

fn parse_numbers(line: &str) -> Vec {
    // "1,two,3,four,5" -> [1, 3, 5]
    // hint: split(','), filter_map, str::parse
}

fn main() {
    let nums = parse_numbers("10,abc,20,xyz,30");
    println!("{:?}", nums);  // [10, 20, 30]
}
Show solution
fn parse_numbers(line: &str) -> Vec {
    line.split(',')
        .filter_map(|s| s.trim().parse::().ok())
        .collect()
}

Task 3: Read a File

Write a function that reads a file and returns the number of lines. Use ? for error propagation. Handle all errors with Box<dyn Error>.

use std::fs;
use std::io;

fn count_lines(path: &str) -> Result> {
    // your code
    // hint: fs::read_to_string?, text.lines().count()
}

fn main() -> Result<(), Box> {
    let n = count_lines("Cargo.toml")?;
    println!("Lines: {n}");
    Ok(())
}
Show solution
fn count_lines(path: &str) -> Result> {
    let text = fs::read_to_string(path)?;
    Ok(text.lines().count())
}

Task 4: Custom Error with thiserror

Create a custom error type for a simple config parser. Use thiserror to derive the Error and Display traits. Two variants: MissingField(&'static str) and BadValue { key: String, val: String }.

use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    // MissingField variant with Display: "missing field: {0}"
    // BadValue variant with Display: "bad value '{val}' for key '{key}'"
}

fn parse_config(text: &str) -> Result<(), ConfigError> {
    // if "name" is missing -> Err(MissingField("name"))
    // if "port" is "abc" -> Err(BadValue { key: "port", val: "abc" })
    // hint: look for lines containing "="
}
Show solution
use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("missing field: {0}")]
    MissingField(&'static str),
    #[error("bad value '{val}' for key '{key}'")]
    BadValue { key: String, val: String },
}

fn parse_config(text: &str) -> Result<(), ConfigError> {
    let mut has_name = false;
    for line in text.lines() {
        if let Some(eq) = line.find('=') {
            let key = line[..eq].trim();
            let val = line[eq + 1..].trim();
            if key == "port" {
                val.parse::().map_err(|_| ConfigError::BadValue {
                    key: key.to_string(),
                    val: val.to_string(),
                })?;
            }
            if key == "name" { has_name = true; }
        }
    }
    if !has_name {
        return Err(ConfigError::MissingField("name"));
    }
    Ok(())
}

Summary