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?
| situation | error type |
|---|---|
| small script, quick tool | Box<dyn Error> everywhere, ? for everything |
| application with distinct failure modes the code reacts to | an enum for the cases that matter, Box<dyn Error> in main |
| library used by others | an 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
- Forgetting
Displayand then using{}: E0277 "AppErrordoesn't implementstd::fmt::Display". impl ErrorwithoutDebugorDisplay: E0277, becauseErrorrequires both.?without aFromimpl: E0277 "?couldn't convert the error toAppError", with a note listing the neededFromimplementation.- Stringly-typed variants:
enum E { Other(String) }for everything defeats the purpose. Make a variant for each case callers will treat differently.
Recap
- An error enum lists the ways an operation can fail, with useful data in each variant.
impl fmt::Displaygives human messages;impl std::error::Error {}makes it a standard error.impl From<OtherError> for MyErrorlets?convert lower-level errors automatically.- Callers
matchon variants to recover selectively;mainmaps errors to messages and exit codes. - Use
Box<dyn Error>for quick programs, enums where code must distinguish failures.
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.