Build the mental model
Using Lesson 11's `Result<T, E>` in a real project raises a practical design question: how should the error type `E` itself be designed? When functions have distinct error sources (`io::Error` from a file read, a custom parse error, a validation error), a custom `enum AnalyzerError { Io(io::Error), Parse(String), Validation(String) }` that represents all of them is the right call — a direct application of Lesson 10's enum concept to the error-handling domain. Implementing the `std::error::Error` trait (Lesson 14's trait concept) on this custom enum (`impl std::error::Error for AnalyzerError {}` plus `Display`) makes it interoperable with the standard error-handling ecosystem (logging libraries, `Box<dyn Error>` return types) — a pattern most library and application code expects as standard. Implementing `From<io::Error> for AnalyzerError` lets the `?` operator keep working across function boundaries — if an inner function returns `io::Error` but the outer function's error type is `AnalyzerError`, `?` performs the conversion automatically, no manual `.map_err(...)` needed — a direct extension of how Lesson 11's `?` operator scales into a real, multi-layer application. Designing custom error types is a core library-authoring skill — popular Rust crates like `thiserror` and `anyhow` mostly just reduce this boilerplate, without changing the underlying concept: making an error type an explicit piece of data.
Connect it to a real scenario
For the content analyzer module, create `enum AnalyzerError { Io(io::Error), EmptyFile(String) }` and implement `Display`, formatting error messages to be human-readable. Write `impl From<io::Error> for AnalyzerError { fn from(e: io::Error) -> Self { AnalyzerError::Io(e) } }` so the `?` operator inside the file-reading function (`fs::read_to_string(path)?`) gets automatic conversion. Chain two function layers with `fn analyze(path: &str) -> Result<u32, AnalyzerError> { let content = fs::read_to_string(path)?; if content.is_empty() { return Err(AnalyzerError::EmptyFile(path.to_string())); } Ok(count_words(&content)) }` — the single `?` operator seamlessly handles both error kinds.
Try the working example
use std::fmt;
use std::fs;
use std::io;
#[derive(Debug)]
enum AnalyzerError {
Io(io::Error),
EmptyFile(String),
}
impl fmt::Display for AnalyzerError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AnalyzerError::Io(e) => write!(f, "io error: {e}"),
AnalyzerError::EmptyFile(path) => write!(f, "empty file: {path}"),
}
}
}
impl std::error::Error for AnalyzerError {}
impl From<io::Error> for AnalyzerError {
fn from(e: io::Error) -> Self {
AnalyzerError::Io(e)
}
}
fn analyze(path: &str) -> Result<u32, AnalyzerError> {
let content = fs::read_to_string(path)?;
if content.is_empty() {
return Err(AnalyzerError::EmptyFile(path.to_string()));
}
Ok(content.split_whitespace().count() as u32)
}
fn main() {
match analyze("missing.md") {
Ok(count) => println!("{count} words"),
Err(e) => println!("error: {e}"),
}
}When the file doesn't exist, you print "error: io error: ...", proving the ? operator automatically converted io::Error into AnalyzerError.5-minute try-it
Add a new `TooManyWords(u32)` variant to `AnalyzerError` — modify `analyze` to return this error when the word count exceeds 5000 — add a matching message for this variant in the `Display` implementation.
One important caution
Blanket-using a single `String` error type (like `Result<T, String>`) — if the caller needs to handle error kinds differently (say, running retry logic only for IO errors), it forces fragile string-parsing; a custom enum preserves that information properly.
Trying to use the `?` operator directly across multiple layers without implementing `From<InnerError> for OuterError` — this triggers an error-type-mismatch compile error; either a manual `.map_err(AnalyzerError::Io)` conversion or a `From` implementation is needed.