Option, Result and Error Handling · about 45 min

Custom Error Types

Why this matters

String errors and Box<dyn Error> are fine for small programs, but a string can only be shown to a person. Code cannot reliably tell "file not found" from "permission denied" by parsing a message. Libraries and larger programs define their own error types, usually enums, so callers can react to each kind of failure, the compiler can check that every kind is handled, and ? can convert lower-level errors automatically. This lesson builds one step by step. It uses impl Trait for Type blocks, which module 10 explains in general; here you will use three specific traits as recipes.

Step 1: an enum of failure kinds

Suppose we parse settings lines like port=8080:

#[derive(Debug)]
enum ConfigError {
    MissingEquals { line: usize },
    UnknownKey(String),
    BadValue { key: String, value: String },
}

fn parse_setting(line_no: usize, text: &str) -> Result<(String, u16), ConfigError> {
    let (key, value) = text.split_once('=').ok_or(ConfigError::MissingEquals { line: line_no })?;
    let key = key.trim();
    let value = value.trim();
    if key != "port" && key != "workers" {
        return Err(ConfigError::UnknownKey(key.to_string()));
    }
    let n: u16 = value.parse().map_err(|_| ConfigError::BadValue {
        key: key.to_string(),
        value: value.to_string(),
    })?;
    Ok((key.to_string(), n))
}

fn main() {
    for (i, text) in ["port = 8080", "verbose", "color = red", "workers = lots"].iter().enumerate() {
        println!("{:?}", parse_setting(i + 1, text));
    }
}
Ok(("port", 8080))
Err(MissingEquals { line: 2 })
Err(UnknownKey("color"))
Err(BadValue { key: "workers", value: "lots" })

Each variant carries the data a caller might need: which line, which key, which value. #[derive(Debug)] makes it printable with {:?}, which main returning Result requires.

Step 2: Display, for humans

Debug output is for programmers. For messages shown to users, implement the Display trait, the trait behind {}:

use std::fmt;

#[derive(Debug)]
enum ConfigError {
    MissingEquals { line: usize },
    UnknownKey(String),
    BadValue { key: String, value: String },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::MissingEquals { line } => write!(f, "line {line}: expected key=value"),
            ConfigError::UnknownKey(k) => write!(f, "unknown setting {k:?}"),
            ConfigError::BadValue { key, value } => write!(f, "{key}: {value:?} is not a number from 0 to 65535"),
        }
    }
}

fn main() {
    let errors = [
        ConfigError::MissingEquals { line: 2 },
        ConfigError::UnknownKey(String::from("color")),
        ConfigError::BadValue { key: String::from("workers"), value: String::from("lots") },
    ];
    for e in &errors {
        println!("error: {e}");
    }
}
error: line 2: expected key=value
error: unknown setting "color"
error: workers: "lots" is not a number from 0 to 65535

The recipe: impl fmt::Display for YourType, with one method fmt that writes into the formatter f using write! (the same formatting syntax as println!) and returns its fmt::Result. Now {}, to_string() and format! all work on your error.

Step 3: the Error trait

std::error::Error is the standard trait that marks a type as an error. With Debug and Display in place, an empty implementation is enough:

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

This lets your error be stored in a Box<dyn Error> (so ? in a main returning Result<(), Box<dyn Error>> accepts it) and lets other code treat it like any standard error. The trait has optional methods, notably source(), which returns the lower-level error that caused this one, so tools can print a chain of causes.

Step 4: From, so ? converts automatically

When your function calls code returning other error types, implement From for each so that ? converts them:

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

#[derive(Debug)]
enum AppError {
    Parse(ParseIntError),
    Negative(i64),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Parse(e) => write!(f, "not a number: {e}"),
            AppError::Negative(n) => write!(f, "{n} is negative"),
        }
    }
}

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

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

fn parse_positive(s: &str) -> Result<i64, AppError> {
    let n: i64 = s.trim().parse()?;
    if n < 0 {
        return Err(AppError::Negative(n));
    }
    Ok(n)
}

fn main() {
    for s in ["12", "-4", "x"] {
        match parse_positive(s) {
            Ok(n) => println!("ok {n}"),
            Err(AppError::Negative(n)) => println!("negative: using {} instead", -n),
            Err(e) => println!("error: {e}"),
        }
    }
}
ok 12
negative: using 4 instead
error: not a number: invalid digit found in string

parse()? produces a ParseIntError; because AppError: From<ParseIntError>, the ? wraps it into AppError::Parse automatically. And the caller in main reacts differently to different kinds: a negative number is recovered from, other errors are reported. That is the payoff over string errors.

Putting it together in main

A common structure for command-line programs: a run function returning your error type, and a main that turns errors into messages and exit codes:

fn run() -> Result<(), AppError> {
    ... all the real work, using ? freely ...
}

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {e}");
        std::process::exit(match e {
            AppError::Usage(_) => 2,
            _ => 1,
        });
    }
}

All error handling policy lives in one place, and the rest of the code just propagates with ?.

How much error type do you need?

situationerror type
small script, quick toolBox<dyn Error> everywhere, ? for everything
application with distinct failure modes the code reacts toan enum for the cases that matter, Box<dyn Error> in main
library used by othersan enum with Display, Error, From impls, so callers can match

In the wider ecosystem, the crates thiserror (generate these impls with attributes) and anyhow (a richer Box<dyn Error> with context) remove most of the boilerplate. They are not available in this sandbox, and knowing what they generate is the point of this lesson.

Common mistakes

Recap

Going further: implement source() for AppError::Parse (return Some(e)), then write a loop that prints an error and all its sources. The std::error::Error docs have an example.

Exercises

Exercise 1 · write code

An error enum for a parser

Lines of input look like NAME: TEMP (for example Oslo: -3.5). Define

#[derive(Debug, PartialEq)]
enum LineError {
    MissingColon,
    EmptyName,
    BadNumber(String),
}

and implement std::fmt::Display for it so the messages are missing ':', empty name and bad number "TEXT". Write fn parse_line(s: &str) -> Result<(String, f64), LineError> (trim the name and the number). The given main prints each parsed line as NAME = TEMP (one decimal) or line N: MESSAGE:

Oslo = -3.5
line 2: missing ':'
line 3: empty name
line 4: bad number "warm"
Cairo = 31.0

for the input lines Oslo: -3.5, Paris 12, : 4, Rome: warm, Cairo:31.

File: main.rs

Starting point

use std::fmt;
use std::io;

fn main() {
    for (i, line) in io::stdin().lines().enumerate() {
        let line = line.unwrap();
        match parse_line(&line) {
            Ok((name, t)) => println!("{name} = {t:.1}"),
            Err(e) => println!("line {}: {e}", i + 1),
        }
    }
}

Example

input

Oslo: -3.5
Paris 12
 : 4
Rome: warm
Cairo:31

expected output

Oslo = -3.5
line 2: missing ':'
line 3: empty name
line 4: bad number "warm"
Cairo = 31.0
Hint 1

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

Hint 2

s.split_once(':').ok_or(LineError::MissingColon)? and num.parse().map_err(|_| LineError::BadNumber(num.to_string()))?

Reference solution
use std::fmt;
use std::io;

#[derive(Debug, PartialEq)]
enum LineError {
    MissingColon,
    EmptyName,
    BadNumber(String),
}

impl fmt::Display for LineError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            LineError::MissingColon => write!(f, "missing ':'"),
            LineError::EmptyName => write!(f, "empty name"),
            LineError::BadNumber(s) => write!(f, "bad number {s:?}"),
        }
    }
}

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

fn parse_line(s: &str) -> Result<(String, f64), LineError> {
    let (name, num) = s.split_once(':').ok_or(LineError::MissingColon)?;
    let name = name.trim();
    if name.is_empty() {
        return Err(LineError::EmptyName);
    }
    let num = num.trim();
    let t: f64 = num.parse().map_err(|_| LineError::BadNumber(num.to_string()))?;
    Ok((name.to_string(), t))
}

fn main() {
    for (i, line) in io::stdin().lines().enumerate() {
        let line = line.unwrap();
        match parse_line(&line) {
            Ok((name, t)) => println!("{name} = {t:.1}"),
            Err(e) => println!("line {}: {e}", i + 1),
        }
    }
}

Exercise 2 · write code

From conversions for ?

AppError wraps two library errors. Add impl From<std::num::ParseIntError> for AppError (producing AppError::Parse) and impl From<std::io::Error> for AppError (producing AppError::Io), so that ? works in run without any map_err. Do not change run or main.

run reads numbers (one per line) and returns their product; main prints it, or exits with status 3 on a parse error. For 2, 3, 7:

42

File: main.rs

Starting point

use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

fn run() -> Result<i64, AppError> {
    let mut product = 1;
    for line in io::stdin().lines() {
        let line = line?;
        let n: i64 = line.trim().parse()?;
        product *= n;
    }
    Ok(product)
}

fn main() {
    match run() {
        Ok(p) => println!("{p}"),
        Err(AppError::Parse(e)) => {
            eprintln!("bad number: {e}");
            std::process::exit(3);
        }
        Err(AppError::Io(e)) => {
            eprintln!("read error: {e}");
            std::process::exit(4);
        }
    }
}

Example

input

2
3
7

expected output

42

Example

input

2
three

expected output

(nothing)
Hint 1

The starter fails with E0277: ? couldn't convert the error to AppError.

Hint 2

impl From<ParseIntError> for AppError { fn from(e: ParseIntError) -> Self { AppError::Parse(e) } }, and the same for io::Error.

Reference solution
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

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

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

fn run() -> Result<i64, AppError> {
    let mut product = 1;
    for line in io::stdin().lines() {
        let line = line?;
        let n: i64 = line.trim().parse()?;
        product *= n;
    }
    Ok(product)
}

fn main() {
    match run() {
        Ok(p) => println!("{p}"),
        Err(AppError::Parse(e)) => {
            eprintln!("bad number: {e}");
            std::process::exit(3);
        }
        Err(AppError::Io(e)) => {
            eprintln!("read error: {e}");
            std::process::exit(4);
        }
    }
}

Exercise 3 · quiz

Why not just String?

What does an error enum like enum ConfigError { Missing(String), Invalid { line: usize } } give you that Result<_, String> does not? Select all that apply.

Answer
  • Callers can match on the kind of error and react differently (retry, default, exit code)

  • The compiler checks that every caller matching on it handles all kinds

  • Structured data (like the line number) stays available to the caller instead of being baked into text

A String error is only good for showing to a human. An enum is data: callers can branch on it, exhaustiveness checking keeps them honest when you add a variant, and fields keep details accessible. Applications often use Box<dyn Error> at the top and enums in the libraries and modules underneath.