In the previous tutorial, we learned enums and pattern matching. We also saw Option<T> for values that might not exist. Now we learn how Rust handles errors.

Most languages use exceptions — you throw an error and hope someone catches it. Rust does not have exceptions. Instead, Rust uses types to represent errors. The compiler forces you to handle them. No surprise crashes. No unhandled exceptions.

Two Kinds of Errors

Rust splits errors into two categories:

  1. Unrecoverable errors — bugs that should never happen. Use panic!.
  2. Recoverable errors — expected failures (file not found, invalid input). Use Result<T, E>.

Most of your code will use Result. Use panic! only for programming mistakes.

panic! — When Something Is Truly Wrong

panic! immediately stops the program with an error message:

fn main() {
    panic!("something went terribly wrong");
}
thread 'main' panicked at 'something went terribly wrong', src/main.rs:2:5

Common situations that panic:

fn main() {
    let v = vec![1, 2, 3];
    let _x = v[10];  // panic: index out of bounds

    // let _n: i32 = "abc".parse().unwrap();  // panic: parse failed
}

panic! is for bugs — things that should be impossible if your code is correct. For expected failures, use Result.

Result<T, E> — The Error Type

Result is an enum with two variants:

enum Result<T, E> {
    Ok(T),    // Success — contains the value
    Err(E),   // Failure — contains the error
}

Functions that can fail return Result:

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10.0, 3.0) {
        Ok(result) => println!("Result: {:.2}", result),
        Err(error) => println!("Error: {}", error),
    }

    match divide(10.0, 0.0) {
        Ok(result) => println!("Result: {:.2}", result),
        Err(error) => println!("Error: {}", error),
    }
}

The caller must handle both cases. You cannot accidentally ignore an error.

Option — Missing Values

Option is for values that might not exist. It is not an error — just absence:

fn find_item(items: &[&str], target: &str) -> Option<usize> {
    for (i, item) in items.iter().enumerate() {
        if *item == target {
            return Some(i);
        }
    }
    None
}

fn main() {
    let fruits = ["apple", "banana", "cherry"];

    match find_item(&fruits, "banana") {
        Some(index) => println!("Found at index {}", index),
        None => println!("Not found"),
    }
}
TypeMeaningVariants
Result<T, E>Operation that can failOk(T) / Err(E)
Option<T>Value that might not existSome(T) / None

unwrap and expect

For quick prototyping, unwrap extracts the value or panics:

fn main() {
    let number: i32 = "42".parse().unwrap();  // Ok → 42
    println!("Parsed: {}", number);

    // "abc".parse::<i32>().unwrap();  // Would panic!
}

expect is the same but with a custom message:

fn main() {
    let number: i32 = "42".parse()
        .expect("failed to parse number");
    println!("Parsed: {}", number);
}

Use unwrap/expect only in prototypes, tests, and when you are certain the value is Ok/Some. In real code, handle errors properly.

The ? Operator — Clean Error Propagation

The ? operator is the best way to handle errors in Rust. It unwraps Ok or returns Err early:

use std::num::ParseIntError;

fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
    let number = input.parse::<i32>()?;  // If Err, return early
    Ok(number * 2)
}

fn main() {
    match parse_and_double("21") {
        Ok(value) => println!("Result: {}", value),   // 42
        Err(e) => println!("Error: {}", e),
    }

    match parse_and_double("abc") {
        Ok(value) => println!("Result: {}", value),
        Err(e) => println!("Error: {}", e),            // Error: invalid digit
    }
}

Without ?, you would need to write this:

fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
    let number = match input.parse::<i32>() {
        Ok(n) => n,
        Err(e) => return Err(e),
    };
    Ok(number * 2)
}

The ? replaces five lines with one. You can chain multiple ? calls:

fn add_strings(a: &str, b: &str) -> Result<i32, ParseIntError> {
    let x = a.parse::<i32>()?;
    let y = b.parse::<i32>()?;
    Ok(x + y)
}

If any parse fails, the function returns the error immediately.

Custom Error Types

For real applications, you define your own error types:

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    InvalidInput(String),
    ParseFailed(ParseIntError),
    OutOfRange(i32),
}

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::ParseFailed(e) => write!(f, "Parse error: {}", e),
            AppError::OutOfRange(n) => write!(f, "Value {} is out of range", n),
        }
    }
}

From Trait — Automatic Error Conversion

The ? operator uses the From trait to convert errors automatically. Implement From on your error type:

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> AppError {
        AppError::ParseFailed(e)
    }
}

fn parse_age(input: &str) -> Result<i32, AppError> {
    let age = input.parse::<i32>()?;  // ParseIntError → AppError automatically

    if age < 0 || age > 150 {
        return Err(AppError::OutOfRange(age));
    }

    Ok(age)
}

Now when parse returns a ParseIntError, the ? operator calls AppError::from(e) and converts it to your custom type.

Combinators — Functional Error Handling

Result and Option have methods for transforming values without match:

map — Transform the Success Value

fn main() {
    let result: Result<i32, String> = Ok(5);
    let doubled = result.map(|n| n * 2);  // Ok(10)
    println!("{:?}", doubled);
}

and_then — Chain Operations That Can Fail

fn parse_positive(input: &str) -> Result<u32, String> {
    input.parse::<i32>()
        .map_err(|e| e.to_string())
        .and_then(|n| {
            if n > 0 {
                Ok(n as u32)
            } else {
                Err(String::from("must be positive"))
            }
        })
}

unwrap_or_else — Default Value on Error

fn main() {
    let value = "abc".parse::<i32>()
        .unwrap_or_else(|_| 0);  // Use 0 if parse fails
    println!("Value: {}", value);  // 0
}

Common Combinators

MethodOnPurpose
mapOk/SomeTransform success value
map_errErrTransform error value
and_thenOk/SomeChain fallible operations
unwrap_orOk/SomeDefault value
unwrap_or_elseOk/SomeDefault from closure
ok_orOptionConvert Option to Result
is_ok / is_someBothCheck without consuming

When to Panic vs When to Return Result

SituationUseWhy
Index out of bounds (bug)panic!Programming mistake
File not foundResultExpected failure
Invalid user inputResultExpected failure
Impossible state reachedpanic!Bug in logic
Network timeoutResultExpected failure
Tests and examplesunwrapQuick and clear
Prototype codeunwrap/expectSpeed over safety

Rule of thumb: If the caller can do something about the error, use Result. If it is a bug that should never happen, use panic!.

A Complete Example

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug, PartialEq)]
enum CalculatorError {
    InvalidNumber(String),
    DivisionByZero,
}

impl fmt::Display for CalculatorError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            CalculatorError::InvalidNumber(s) => write!(f, "Invalid number: {}", s),
            CalculatorError::DivisionByZero => write!(f, "Division by zero"),
        }
    }
}

impl From<ParseIntError> for CalculatorError {
    fn from(e: ParseIntError) -> CalculatorError {
        CalculatorError::InvalidNumber(e.to_string())
    }
}

fn calculate(a: &str, b: &str) -> Result<i32, CalculatorError> {
    let x = a.parse::<i32>()?;
    let y = b.parse::<i32>()?;

    if y == 0 {
        return Err(CalculatorError::DivisionByZero);
    }

    Ok(x / y)
}

fn main() {
    match calculate("100", "4") {
        Ok(result) => println!("100 / 4 = {}", result),
        Err(e) => println!("Error: {}", e),
    }

    match calculate("100", "0") {
        Ok(result) => println!("100 / 0 = {}", result),
        Err(e) => println!("Error: {}", e),
    }

    match calculate("abc", "4") {
        Ok(result) => println!("abc / 4 = {}", result),
        Err(e) => println!("Error: {}", e),
    }
}

Summary

ConceptSyntaxPurpose
panic!panic!("msg")Unrecoverable error
ResultResult<T, E>Recoverable error
OptionOption<T>Value might not exist
?value?Propagate error or unwrap
unwrap.unwrap()Extract or panic
expect.expect("msg")Extract or panic with message
Custom errorenum MyError { ... }Application-specific errors
Fromimpl From<E> for MyErrorAuto-convert with ?
Combinators.map(), .and_then()Transform without match

Source Code

View source code on GitHub →

What’s Next?

You now have the tools to write real Rust programs — ownership, borrowing, structs, enums, and error handling. In the next tutorial, we learn traits — how to define shared behavior across types.

Next: Rust Tutorial #9: Traits — Shared Behavior