Rust track

Lesson 16 · Part 4: Error Handling, Panics & Custom Errors

The ? Operator

How ? propagates errors and converts them with From

Part 4: Error Handling, Panics & Custom Errors4 min readFree lesson

The ? operator is a first-class citizen in Rust error handling. If Result is Err or Option is None, it immediately exits the entire function and returns that error to the caller. Deeply nested matchs turn into a single line, and when combined with the From trait, different error types can be smoothly grouped together. This is why almost every line in library code ends with ?.

Learning Objectives

  • Understand the principle of how ? works (early return).
  • Use ? with both Result<T, E> and Option<T>.
  • Observe how other error types are automatically converted thanks to the From trait.
  • Learn the pattern of setting the return type of main to Result<(), Box<dyn Error>>.

Key Concepts

1) What ? Does

let n: i32 = "42".parse()?;

The above single line is essentially as follows.

let n: i32 = match "42".parse() {
    Ok(v) => v,
    Err(e) => return Err(e.into()),
};

Core points:

  • Ok(v) → The value v remains in place.
  • Err(e) → The function itself immediately returns with Err(e.into()).

2) Where ? Applies

The return type of the function must be compatible with ?.

  • Function returning Result<T, E>: ? can be used as a Result<_, _> value.
  • Function that returns Option<T>: ? can be used with Option<_> values.

If you want to mix Result and Option in the same function, unify to one side using ok_or / ok, or use the 16-pattern (common error type).

3) Automatic conversion of From

? is not simple propagation. If From<Original_Error> for Function_Error is implemented, it is automatically converted.

impl From<ParseIntError> for MyError { /* ... */ }

fn parse_age(s: &str) -> Result<u8, MyError> {
    let n: i32 = s.parse()?; //ParseIntError → MyError automatic
    /* ... */
}

Thanks to this, ? can neatly group multiple types of errors in one line.

4) Return type of main

main can also return Result.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let s = std::fs::read_to_string("data.txt")?;
    let n: i32 = s.trim().parse()?;
    println!("n = {n}");
    Ok(())
}

Box<dyn Error> means "I will accept any error." Full-fledged libraries prefer 18 custom error types more.

Examples

Example 1 — ex01_read_file.rs: File + Parsing

//Example 16-1: Reading a file and parsing numbers — naturally propagate two types of errors with ?
//
//`?` immediately returns from the function if the Result/Option is Err/None and
//passes the error back to the caller. Both different error types are automatically converted to `Box<dyn Error>`
//and combined in one line.

use std::error::Error;
use std::fs;

fn read_first_number(path: &str) -> Result<i32, Box<dyn Error>> {
    let content = fs::read_to_string(path)?; // io::Error
    let n: i32 = content.trim().parse()?;    // ParseIntError
    Ok(n)
}

fn main() -> Result<(), Box<dyn Error>> {
    //Create a file at a temporary path and read it.
    let path = std::env::temp_dir().join("lecture16_ex01.txt");
    fs::write(&path, "42")?;

    let n = read_first_number(path.to_str().expect("UTF-8 path"))?;
    println!("Read number: {n}");

    Ok(())
}

Example 2 — ex02_option_q.rs: ? of Option

//Example 2 of Episode 16: ? also works with Option
//
//When a function's return type is Option<T>, ? propagates None directly to the caller.

fn last_char_uppercase(s: &str) -> Option<char> {
    let last = s.chars().last()?;   //If the string is empty, return None immediately
    last.to_uppercase().next()
}

fn main() {
    for s in ["hello", "rust", ""] {
        println!("{:?} → {:?}", s, last_char_uppercase(s));
    }
}

Example 3 — ex03_from.rs: From automatic conversion

//Example 3 of Chapter 16: `?` and automatic conversion with From
//
//The `?` does not simply return the error as is; if the From trait is implemented,
//it **automatically converts** to the caller's error type.

use std::num::ParseIntError;

#[derive(Debug)]
enum MyError {
    Parse(ParseIntError),
    OutOfRange(i32),
}

//Implementing From to wrap standard library errors into our enum
impl From<ParseIntError> for MyError {
    fn from(e: ParseIntError) -> Self {
        MyError::Parse(e)
    }
}

fn parse_age(s: &str) -> Result<u8, MyError> {
    let n: i32 = s.parse()?; //ParseIntError → automatic conversion to MyError
    if !(0..=150).contains(&n) {
        return Err(MyError::OutOfRange(n));
    }
    Ok(n as u8)
}

fn main() {
    for s in ["25", "0", "abc", "200", "-3"] {
        println!("{s:?} → {:?}", parse_age(s));
    }
}

Common Mistakes

Q. I use ? in main but get the error "the ? operator can only be used..."

A. It is because the return type of fn main() is (). Change it to fn main() -> Result<(), Box<dyn Error>>.

Q. I want to propagate Option::None as ? inside a Result function.

A. It cannot be used as-is. First, convert ok_or(...) to Result.

let v = opt.ok_or("No value")?;

Q. Do I need to implement From directly?

A. Many Froms are already implemented among standard errors, and if you receive it as Box<dyn Error>, it is almost automatic. When creating your own error type, you need impl From<...> directly, but it can be automated with the thiserror macro in chapter 18.

Q. Can I always use ? in place of unwrap?

A. ? only makes sense when the caller is prepared to receive errors. For small script mains, expect is sufficient — just be careful with the messages shown to the user.

Summary

  • ? terminates the function early when Err/None, propagating the error.
  • Works with both Result and Option — but the function return type must be compatible.
  • Multiple error types can be naturally combined with From trait auto-conversion.
  • main can return Result<(), Box<dyn Error>> as well.

Try it yourself

Solve the assignment below. The reference solution is shown below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/question_mark/Cargo.tomlTOML
[package]
name = "question_mark"
version = "0.1.0"
edition = "2021"
default-run = "question_mark"

[dependencies]
src/question_mark/src/main.rsRust
fn main() {
    println!("This crate has three executable examples.");
    println!();
    println!("  cargo run --bin ex01_read_file");
    println!("  cargo run --bin ex02_option_q");
    println!("  cargo run --bin ex03_from");
}

Assignment

Problem 1 — Adding Two Numbers (Propagating parse errors)

  • File name: homework_01/src/main.rs
  • Key concept: ? operator, Result

Requirements

  • Define the function add_strings(a: &str, b: &str) -> Result<i32, std::num::ParseIntError>.
  • Parse the two inputs as i32 and return their sum.
  • In main, print the results of the following two calls.
    • ("3", "4") → Ok(7)
    • ("12", "x") → Err(...)

Expected output

("3", "4") → Ok(7)
("12", "x") → Err(...)

Hint

  • let x: i32 = a.parse()?;, let y: i32 = b.parse()?;
  • The Err(...) part of the second call can display the Debug format of ParseIntError in the output. It does not need to match the exact message.

Problem 2 — first_capital (Option ?)

  • File name: homework_02/src/main.rs
  • Key concept: Option<T>'s ?, chars().find

Requirements

  • Implement the function first_capital(s: &str) -> Option<char>.
  • Return the first ASCII capital letter (A~Z) that appears in the input. If none, return None.
  • In main, print the results for the three inputs "hello World", "rust", "".

Expected output

"hello World" → Some('W')
"rust" → None
"" → None

Hint

  • s.chars().find(|c| c.is_ascii_uppercase()).
  • Or let c = s.chars().find(|c| c.is_ascii_uppercase())?; then Some(c).

Check your answers

Try solving it yourself first, then compare your answer with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/Cargo.tomlTOML
[package]
name = "homework_01"
version = "0.1.0"
edition = "2021"

[dependencies]
homework_01/src/main.rsRust
//Key Points:
//- Is the function return type Result<_, ParseIntError>? The ? propagates the same error both times.
//- On the caller side, the result is received at once and branched.

use std::num::ParseIntError;

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

fn main() {
    let cases = [("3", "4"), ("12", "x")];
    for (a, b) in cases {
        let label = format!("({:?}, {:?})", a, b);
        match add_strings(a, b) {
            Ok(n) => println!("{label} → Ok({n})"),
            Err(e) => println!("{label} → Err({e:?})"),
        }
    }
}
homework_02/Cargo.tomlTOML
[package]
name = "homework_02"
version = "0.1.0"
edition = "2021"

[dependencies]
homework_02/src/main.rsRust
//Key Points:
//- `find` returns the first element that satisfies the condition as an Option.
//- `?` works in Option functions as well, but in this example, returning it as is is also concise.

fn first_capital(s: &str) -> Option<char> {
    s.chars().find(|c| c.is_ascii_uppercase())
}

fn main() {
    for s in ["hello World", "rust", ""] {
        println!("{:?} → {:?}", s, first_capital(s));
    }
}

Learn this lesson with a mentor

Work through the Rust track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now