Skip to content

14. Error types for a library

A library function reads a price list: one SKU,price_in_cents pair on each line. One caller is an import job. It skips lines with a bad price, and stops on every other error.

  1. WorseResult<_, String>: good messages, but the caller can only read text. The ParseIntError is gone.

    pub fn parse_price_list(text: &str) -> Result<PriceList, String> {
    let mut prices = PriceList::new();
    for (index, line) in text.lines().enumerate() {
    let (sku, cents) = line
    .split_once(',')
    .ok_or(format!("line {}: missing comma", index + 1))?;
    let cents = cents
    .trim()
    .parse()
    .map_err(|e| format!("line {}: bad price: {e}", index + 1))?;
    prices.insert(sku.trim().to_string(), cents);
    }
    Ok(prices)
    }
  2. OKBox<dyn Error>: the ParseIntError is kept, but the line number is lost, and the caller must downcast to find the kind.

    pub fn parse_price_list(text: &str) -> Result<PriceList, Box<dyn Error + Send + Sync>> {
    let mut prices = PriceList::new();
    for (index, line) in text.lines().enumerate() {
    let (sku, cents) = line
    .split_once(',')
    .ok_or(format!("line {}: missing comma", index + 1))?;
    let cents = cents.trim().parse::<u64>()?; // the ParseIntError is kept, the line number is not
    prices.insert(sku.trim().to_string(), cents);
    }
    Ok(prices)
    }
  3. BetterA thiserror enum: one variant for each failure, with the line number and the cause.

    #[derive(Debug, PartialEq, Eq, thiserror::Error)]
    #[non_exhaustive]
    pub enum PriceListError {
    #[error("line {line}: expected `SKU,price`")]
    MissingComma { line: usize },
    #[error("line {line}: price is not a whole number of cents")]
    BadPrice {
    line: usize,
    #[source]
    source: ParseIntError,
    },
    }
    pub fn parse_price_list(text: &str) -> Result<PriceList, PriceListError> {
    let mut prices = PriceList::new();
    for (index, line) in text.lines().enumerate() {
    let line_number = index + 1;
    let (sku, cents) = line
    .split_once(',')
    .ok_or(PriceListError::MissingComma { line: line_number })?;
    let cents = cents
    .trim()
    .parse()
    .map_err(|source| PriceListError::BadPrice {
    line: line_number,
    source,
    })?;
    prices.insert(sku.trim().to_string(), cents);
    }
    Ok(prices)
    }

With the “Worse” version, the import job can only parse the message: if msg.contains("bad price"). When someone improves the wording of a message, the import job silently changes behavior. See S07: Result<T, String> or stringly-typed errors.

With the “OK” version, the import job can ask “is the cause a ParseIntError?” with error.downcast_ref::<ParseIntError>(). That works, but nothing in the signature tells the caller which types to try. And ? on the parse result returned the ParseIntError without the line number, so the message is only “invalid digit found in string”. See S09: Error context lost (map_err(|_| ...)).

With the “Better” version, the import job matches on a variant, and the compiler checks it:

pub fn bad_price_line(error: &PriceListError) -> Option<usize> {
match error {
PriceListError::BadPrice { line, .. } => Some(*line),
_ => None,
}
}

#[non_exhaustive] on the enum tells callers outside the crate that new variants can come in a later version, so their match must have a _ arm. The library can then add a variant without a breaking change. It is the equivalent of a Java method that can throw new subclasses of a documented exception.

Each version compared with the Worse version
CriterionOKBetter
Correctness Same Same
All three return an error for the same inputs.
Failure behavior Worse Better
"OK" keeps the cause but loses the line number. "Better" keeps both.
Performance Same Same
Errors are rare. Their cost does not matter here.
Readability Better Worse
"OK" is the shortest. "Better" needs an enum of about 12 lines.
API ergonomics Better Better
"OK" works with any error-reporting code. "Better" also gives callers a stable type to match on.
  • Box<dyn Error> is fine in examples, tests, prototypes, and small binaries. In a library, it hides the failure modes from the signature.
  • anyhow::Error is the application version of Box<dyn Error>, with context and a cause chain. Use it in binaries, not in a library’s public API.
  • One enum for each operation, not one for the whole crate. A caller of parse_price_list should not need to handle “database unavailable”.
  • thiserror is optional. The generated code is the same as a hand-written Display and Error implementation (see S07). Some libraries avoid the dependency. Both are fine.
  • String errors are acceptable in a test helper, or in a private function whose errors only go into a log.