14. Error types for a library
The task
Section titled “The task”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.
The versions
Section titled “The versions”Worse
Result<_, String>: good messages, but the caller can only read text. TheParseIntErroris 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)}OK
Box<dyn Error>: theParseIntErroris 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 notprices.insert(sku.trim().to_string(), cents);}Ok(prices)}BetterA
thiserrorenum: 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)}
What each caller can do
Section titled “What each caller can do”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.
Verdict
Section titled “Verdict”| Criterion | OK | Better |
|---|---|---|
| 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. | ||
Trade-offs
Section titled “Trade-offs”Box<dyn Error>is fine in examples, tests, prototypes, and small binaries. In a library, it hides the failure modes from the signature.anyhow::Erroris the application version ofBox<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_listshould not need to handle “database unavailable”. thiserroris optional. The generated code is the same as a hand-writtenDisplayandErrorimplementation (see S07). Some libraries avoid the dependency. Both are fine.Stringerrors are acceptable in a test helper, or in a private function whose errors only go into a log.
Related
Section titled “Related”- Smells: S07:
Result<T, String>or stringly-typed errors, S09: Error context lost (map_err(|_| ...)). - Concepts: Result, not exceptions.
- Gallery: 1. Read a config file, for the application side with
anyhow.