Skip to content

Rust Option, Result and Error Handling

Rust Option represents absence, while Result represents success or failure. This lesson parses Raspberry Pi-style millidegree fixtures without turning a missing value or invalid text into zero, then combines the first part of the course into a tested report.

Prerequisites and learning outcome

Complete strings, vectors and hash maps. You will read nested Option/Result types, return errors with ?, decide which layer handles a failure, and distinguish normal input errors from a panic. No file access or sensor hardware is needed.

Absence and failure are different contracts

Type Variants Meaning here
Option<i32> Some(value), None A reading exists, or it is absent
Result<i32, ParseIntError> Ok(value), Err(error) Text parsed, or parsing failed
Result<Option<i32>, ParseIntError> Ok(Some(value)), Ok(None), Err(error) A parsed value, intentional absence, or failed parsing

These are standard-library enums. The nested return type states our input policy: trim surrounding whitespace, interpret empty text as missing, and otherwise require a signed 32-bit integer. That empty-input rule is an application decision, not a universal Rust convention.

Zero and negative integers remain valid parsed values. We check representation, not a sensor's physical range. The exercise notice threshold of 60,000 millidegrees is not a Pi throttling limit.

Build a parser and summary

cargo new pi_results
cd pi_results

Replace src/main.rs with:

use std::num::ParseIntError;

fn parse_reading(input: &str) -> Result<Option<i32>, ParseIntError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }
    let value = trimmed.parse::<i32>()?;
    Ok(Some(value))
}

struct Summary {
    measured: usize,
    missing: usize,
    invalid: usize,
    notice: usize,
}

fn summarise(inputs: &[&str]) -> Summary {
    let mut summary = Summary {
        measured: 0,
        missing: 0,
        invalid: 0,
        notice: 0,
    };

    for input in inputs {
        match parse_reading(input) {
            Ok(Some(value)) => {
                summary.measured += 1;
                if value >= 60_000 {
                    summary.notice += 1;
                }
            }
            Ok(None) => summary.missing += 1,
            Err(_error) => summary.invalid += 1,
        }
    }
    summary
}

fn main() {
    // Fixtures, including a real zero and an out-of-range integer.
    let inputs = ["46700", "60000", "63200", "", "bad", "0", "2147483648"];
    let summary = summarise(&inputs);
    println!(
        "measured={}, missing={}, invalid={}, notice={}",
        summary.measured, summary.missing, summary.invalid, summary.notice
    );
    println!("zero={:?}", parse_reading("0"));
    println!("empty={:?}", parse_reading(""));
    println!("invalid rejected={}", parse_reading("bad").is_err());
}

#[cfg(test)]
mod tests {
    use super::{parse_reading, summarise};

    #[test]
    fn zero_and_negative_values_are_present() {
        assert_eq!(parse_reading("0").expect("valid fixture"), Some(0));
        assert_eq!(parse_reading(" -500 ").expect("valid fixture"), Some(-500));
    }

    #[test]
    fn whitespace_only_input_is_missing() {
        assert_eq!(parse_reading(" \n\t ").expect("missing fixture"), None);
    }

    #[test]
    fn invalid_text_is_an_error() {
        assert!(parse_reading("bad").is_err());
        assert!(parse_reading("46.7").is_err());
    }

    #[test]
    fn integers_outside_i32_are_errors() {
        assert!(parse_reading("2147483648").is_err());
        assert!(parse_reading("-2147483649").is_err());
    }

    #[test]
    fn every_fixture_is_accounted_for() {
        let inputs = ["46700", "60000", "63200", "", "bad", "0", "2147483648"];
        let summary = summarise(&inputs);
        assert_eq!(summary.measured, 4);
        assert_eq!(summary.missing, 1);
        assert_eq!(summary.invalid, 2);
        assert_eq!(summary.notice, 2);
        assert_eq!(
            summary.measured + summary.missing + summary.invalid,
            inputs.len()
        );
    }

    #[test]
    fn empty_collection_has_zero_counts() {
        let summary = summarise(&[]);
        assert_eq!(summary.measured + summary.missing + summary.invalid, 0);
        assert_eq!(summary.notice, 0);
    }

    #[test]
    fn notice_includes_the_threshold() {
        let summary = summarise(&["59999", "60000", "60001"]);
        assert_eq!(summary.measured, 3);
        assert_eq!(summary.notice, 2);
    }

    #[test]
    #[should_panic(expected = "intentionally invalid fixture")]
    fn expect_panics_when_the_assumption_is_false() {
        let _value = "bad".parse::<i32>().expect("intentionally invalid fixture");
    }
}
1
2
3
cargo run --quiet
cargo test
cargo fmt --check

Expected output:

1
2
3
4
measured=4, missing=1, invalid=2, notice=2
zero=Ok(Some(0))
empty=Ok(None)
invalid rejected=true

Read the nested match one layer at a time

First distinguish Ok from Err. For Ok, distinguish Some from None. Exhaustive patterns force the summary to handle all three outcomes. notice is a subset of measured, not a fourth mutually exclusive input category.

The parser preserves ParseIntError for its caller. The summary deliberately counts errors rather than printing their reasons or failing the entire batch. That is a batch-report policy, not evidence that errors are irrelevant. A CLI that must return failure or identify individual bad records needs a different outer contract; the later CLI lesson handles exit status and diagnostics.

The Option documentation, Result documentation and ParseIntError API are the authoritative API references.

What the question-mark operator does

trimmed.parse::<i32>() returns a Result. The ::<i32> syntax selects the parsed type explicitly; without it, inference would need another constraint. ? extracts the Ok value or returns early with the error. It neither logs, retries nor repairs the input.

For this parser, the error type already matches its return type. Conceptually, the extraction is equivalent to matching Ok(value) => value and Err(error) => return Err(error). In APIs with differing error types, propagation can require a supported conversion or explicit mapping. We introduce custom error types after traits.

The return value is Ok(Some(value)), not the bare value: success and presence are independent wrappers. Likewise, Ok(None) means our policy accepted missing input, not that parsing a number failed. See recoverable errors.

You can also use ? with Option in an Option-returning function to propagate None. It does not automatically translate None into an arbitrary Result error. In the usual function bodies here, the enclosing return type must support the propagation.

Panic is not normal input validation

unwrap and expect extract a success/present value but panic on Err/None. The tests use expect for fixtures whose success is an assertion, and include a deliberate panic to demonstrate a false assumption. The parser itself does not unwrap external input.

A panic may unwind and run cleanup, or abort, depending on configuration and circumstances. Do not use it as a universal recoverable-error mechanism or rely on destructors during abort. catch_unwind is not a general substitute for returning Result, and it does not catch aborting panics. See panic policy.

Methods such as unwrap_or choose a fallback; they do not make a missing value equivalent to a real measurement. Borrowed inspection with as_ref can avoid consuming an owned Option or Result. Later lessons add closure-based transformations; match is sufficient for this stage.

Deliberately failing propagation from unit

Use a separate scratch project for this complete example:

1
2
3
4
fn main() {
    let value = "46700".parse::<i32>()?;
    println!("{value}");
}

Expect E0277 from cargo check: this main returns unit, so it cannot propagate the parsing Result with ?. One possible signature is fn main() -> Result<(), std::num::ParseIntError>, with an Ok(()) final value. Alternatively, match and handle the error locally. Choosing unwrap merely to make it compile changes the failure policy.

Part I checkpoint: explain and extend the report

Before moving to project organisation, work through these tasks:

  1. Explain why the seven fixtures produce four measured, one missing and two invalid results. Count a real zero as measured; count an overflowing literal as invalid.
  2. Replace the fixed array with vec![...] containing the same fixtures and pass &inputs: expect identical output. The Vec can grow; the function still borrows a slice.
  3. Replace ? with the explicit Ok/Err match described above: tests and output should stay unchanged. Explain which layer returns an error and which layer counts it.
  4. Change the parser's missing-input policy to return Ok(Some(0)): the whitespace test should fail. Restore it; do not silently erase absence.
  5. Add a diagnostic that prints the invalid fixture with its error, without converting it to a valid measurement. Keep data acquisition and permissions outside this exercise.

You are ready for Part II when you can explain the ownership of input text, the slice parameter, the returned enums, the summary struct, the boundary rule and the difference between compile-time rejection and a runtime input error. Passing tests alone does not replace that explanation.

Verification and next step

On October 10, 2026, this lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all 8 tests, formatting and debug/release output comparisons passed. Vec input, explicit error matching, invalid-record diagnostics and the repaired Result-returning main were also checked. The unit-returning main was rejected with E0277; changing absence into zero triggered the expected test failure. No live sensor or performance measurement is claimed.

Next: crates, modules, paths and visibility, splitting a binary from its reusable library.

Previous: strings and collections · Course overview

Donate