Lesson 49 of 94
Custom Error Types
Result & the ? Operator used String and simple enums as errors. Real applications need more: errors that print nicely, carry their underlying cause, convert automatically between layers, and can be inspected by callers. This lesson builds all of that by hand with the standard library, so the next lesson's libraries (thiserror, anyhow) hold no mystery.
| Concept | Rust |
|---|---|
| Error type | enum AppError { ... } + impl std::error::Error |
| One case per failure | Enum variants per failure |
| Message | Display ({}) |
| Underlying cause | Error::source() |
| Wrapping a lower-level error | A variant that holds the lower-level error |
| Handling a specific error | match err { AppError::Io(e) => ... } |
| Any error type | Result<T, Box<dyn Error>> |
| Checking for a concrete type | err.downcast_ref::<NotFound>() |
1. Why String errors aren't enough
A String error is readable, but callers can't react to which failure happened without parsing text.
fn withdraw(balance: u64, amount: u64) -> Result<u64, String> {
if amount == 0 {
return Err(String::from("amount must be positive"));
}
if amount > balance {
return Err(format!("insufficient funds: need {amount}, have {balance}"));
}
Ok(balance - amount)
}
fn main() {
match withdraw(100, 250) {
Ok(left) => println!("left {left}"),
Err(msg) if msg.starts_with("insufficient") => println!("offer overdraft? ({msg})"), // fragile!
Err(msg) => println!("error: {msg}"),
}
}offer overdraft? (insufficient funds: need 250, have 100)Matching on message text breaks the day someone rewords the message.
2. An error enum
Each failure becomes a variant, carrying the data the caller might need.
#[derive(Debug, PartialEq)]
enum WithdrawError {
ZeroAmount,
InsufficientFunds { needed: u64, available: u64 },
AccountLocked,
}
fn withdraw(balance: u64, locked: bool, amount: u64) -> Result<u64, WithdrawError> {
if locked {
return Err(WithdrawError::AccountLocked);
}
if amount == 0 {
return Err(WithdrawError::ZeroAmount);
}
if amount > balance {
return Err(WithdrawError::InsufficientFunds { needed: amount, available: balance });
}
Ok(balance - amount)
}
fn main() {
match withdraw(100, false, 250) {
Ok(left) => println!("left {left}"),
Err(WithdrawError::InsufficientFunds { needed, available }) => println!("short by {}", needed - available),
Err(other) => println!("refused: {:?}", other),
}
assert_eq!(withdraw(100, true, 5), Err(WithdrawError::AccountLocked));
println!("locked accounts are refused");
}short by 150
locked accounts are refusedDeriving PartialEq makes errors easy to check in tests. assert_eq! panics if the two values differ, which is how Rust tests assert (see Testing).
3. Display: a message for humans
Debug output is for developers. Implement Display for the message users and logs should see.
use std::fmt;
#[derive(Debug)]
enum WithdrawError {
ZeroAmount,
InsufficientFunds { needed: u64, available: u64 },
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::ZeroAmount => write!(f, "amount must be greater than zero"),
WithdrawError::InsufficientFunds { needed, available } => {
write!(f, "insufficient funds: needed {needed}, available {available}")
}
}
}
}
fn main() {
let e = WithdrawError::InsufficientFunds { needed: 250, available: 100 };
println!("Display: {e}");
println!("Debug: {:?}", e);
println!("Display: {}", WithdrawError::ZeroAmount);
}Display: insufficient funds: needed 250, available 100
Debug: InsufficientFunds { needed: 250, available: 100 }
Display: amount must be greater than zero4. Implementing std::error::Error
std::error::Error is the standard trait for error types. It requires Debug and Display, and its methods all have defaults, so an empty impl is enough. Implementing it lets your error work with Box<dyn Error>, error-reporting libraries, and ? conversions.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct ValidationError {
field: &'static str,
reason: String,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "invalid {}: {}", self.field, self.reason)
}
}
impl Error for ValidationError {}
fn check_age(age: i32) -> Result<u8, ValidationError> {
u8::try_from(age)
.ok()
.filter(|a| (13..=120).contains(a))
.ok_or(ValidationError { field: "age", reason: format!("{age} is not between 13 and 120") })
}
fn main() {
let err: Box<dyn Error> = Box::new(check_age(7).unwrap_err());
println!("{err}");
println!("{:?}", check_age(34));
}invalid age: 7 is not between 13 and 120
Ok(34)5. Box<dyn Error>: any error at all
Box<dyn Error> can hold any error type. ? converts any Error into it automatically, so functions that call many different fallible operations become simple.
use std::error::Error;
fn parse_rect(input: &str) -> Result<(u32, f64), Box<dyn Error>> {
let (w, ratio) = input.split_once(':').ok_or("expected 'width:ratio'")?;
let width: u32 = w.trim().parse()?; // ParseIntError
let ratio: f64 = ratio.trim().parse()?; // ParseFloatError
Ok((width, ratio))
}
fn main() {
for input in ["1920:1.78", "1920", "wide:1.5", "800:tall"] {
match parse_rect(input) {
Ok(r) => println!("{input:>10}: {:?}", r),
Err(e) => println!("{input:>10}: error: {e}"),
}
}
} 1920:1.78: (1920, 1.78)
1920: error: expected 'width:ratio'
wide:1.5: error: invalid digit found in string
800:tall: error: invalid float literalEven a &str like "expected 'width:ratio'" converts into Box<dyn Error> with ?.
6. main returning Box<dyn Error>
fn main() -> Result<(), Box<dyn Error>> is the standard signature for small programs. An error is printed with its Debug format, and the exit code is 1.
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let workers: u8 = "8".parse()?;
println!("workers = {workers}");
let port: u16 = "70000".parse()?;
println!("port = {port}");
Ok(())
}workers = 8
Error: ParseIntError { kind: PosOverflow }7. From conversions: making ? convert automatically
A custom error enum usually wraps lower-level errors in variants. Implementing From<LowLevel> for YourError lets ? perform the wrapping automatically, as shown in Conversion Traits.
use std::fmt;
use std::num::{ParseFloatError, ParseIntError};
#[derive(Debug)]
enum ConfigError {
BadInt(ParseIntError),
BadFloat(ParseFloatError),
Missing(&'static str),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::BadInt(e) => write!(f, "invalid integer: {e}"),
ConfigError::BadFloat(e) => write!(f, "invalid decimal: {e}"),
ConfigError::Missing(key) => write!(f, "missing setting '{key}'"),
}
}
}
impl std::error::Error for ConfigError {}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::BadInt(e)
}
}
impl From<ParseFloatError> for ConfigError {
fn from(e: ParseFloatError) -> Self {
ConfigError::BadFloat(e)
}
}
fn load(workers: Option<&str>, ratio: &str) -> Result<(u32, f64), ConfigError> {
let w: u32 = workers.ok_or(ConfigError::Missing("workers"))?.parse()?;
let r: f64 = ratio.parse()?;
Ok((w, r))
}
fn main() {
println!("{:?}", load(Some("4"), "0.5"));
for (w, r) in [(Some("four"), "0.5"), (Some("4"), "half"), (None, "0.5")] {
println!("{}", load(w, r).unwrap_err());
}
}Ok((4, 0.5))
invalid integer: invalid digit found in string
invalid decimal: invalid float literal
missing setting 'workers'8. source(): exposing the underlying cause
Error::source() returns the error that caused this one. Tools can then walk and print the whole chain.
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
struct PortError {
raw: String,
cause: ParseIntError,
}
impl fmt::Display for PortError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "could not read port from '{}'", self.raw)
}
}
impl Error for PortError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.cause)
}
}
fn parse_port(raw: &str) -> Result<u16, PortError> {
raw.parse().map_err(|cause| PortError { raw: raw.to_string(), cause })
}
fn main() {
let err = parse_port("80a").unwrap_err();
println!("error: {err}");
if let Some(cause) = err.source() {
println!("caused by: {cause}");
}
}error: could not read port from '80a'
caused by: invalid digit found in stringDon't repeat the cause's message in your own Display. Each layer describes its own step, and the chain tells the whole story.
9. Printing a whole error chain
A helper that follows source() until the end prints every layer.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct Layer {
message: &'static str,
cause: Option<Box<dyn Error>>,
}
impl fmt::Display for Layer {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.message)
}
}
impl Error for Layer {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.cause.as_deref()
}
}
fn report(err: &dyn Error) -> String {
let mut out = format!("error: {err}");
let mut current = err.source();
while let Some(cause) = current {
out.push_str(&format!("\n caused by: {cause}"));
current = cause.source();
}
out
}
fn main() {
let root = "abc".parse::<u32>().unwrap_err();
let middle = Layer { message: "invalid quantity in line 12", cause: Some(Box::new(root)) };
let top = Layer { message: "could not import orders.csv", cause: Some(Box::new(middle)) };
println!("{}", report(&top));
}error: could not import orders.csv
caused by: invalid quantity in line 12
caused by: invalid digit found in string10. Errors for each layer of an application
A service layer wraps repository errors, which wrap parsing errors. Each layer adds meaning, and callers match on the layer they care about.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
enum RepoError {
NotFound(u32),
Corrupt { id: u32, detail: String },
}
#[derive(Debug)]
enum ServiceError {
Repo(RepoError),
Forbidden { user: String },
}
impl fmt::Display for RepoError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RepoError::NotFound(id) => write!(f, "record {id} not found"),
RepoError::Corrupt { id, detail } => write!(f, "record {id} is corrupt: {detail}"),
}
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ServiceError::Repo(_) => write!(f, "storage failure"),
ServiceError::Forbidden { user } => write!(f, "{user} may not read this document"),
}
}
}
impl Error for RepoError {}
impl Error for ServiceError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ServiceError::Repo(e) => Some(e),
ServiceError::Forbidden { .. } => None,
}
}
}
impl From<RepoError> for ServiceError {
fn from(e: RepoError) -> Self {
ServiceError::Repo(e)
}
}
fn load(id: u32) -> Result<String, RepoError> {
match id {
1 => Ok(String::from("quarterly plan")),
2 => Err(RepoError::Corrupt { id, detail: String::from("checksum mismatch") }),
_ => Err(RepoError::NotFound(id)),
}
}
fn read_document(user: &str, id: u32) -> Result<String, ServiceError> {
if user == "guest" {
return Err(ServiceError::Forbidden { user: user.to_string() });
}
Ok(load(id)?) // RepoError -> ServiceError via From
}
fn main() {
for (user, id) in [("ana", 1), ("ana", 2), ("ana", 9), ("guest", 1)] {
match read_document(user, id) {
Ok(doc) => println!("{user}/{id}: {doc}"),
Err(e) => println!("{user}/{id}: {e}{}", e.source().map(|s| format!(" ({s})")).unwrap_or_default()),
}
}
}ana/1: quarterly plan
ana/2: storage failure (record 2 is corrupt: checksum mismatch)
ana/9: storage failure (record 9 not found)
guest/1: guest may not read this document11. Recovering from specific errors
Because errors are values, "retry on this error, give up on that one" is just a match.
#[derive(Debug)]
enum FetchError {
Timeout,
RateLimited { retry_after_secs: u64 },
NotFound,
}
fn fetch(attempt: u32) -> Result<&'static str, FetchError> {
match attempt {
1 => Err(FetchError::Timeout),
2 => Err(FetchError::RateLimited { retry_after_secs: 30 }),
_ => Ok("payload"),
}
}
fn main() {
let mut attempt = 1;
let mut waited = 0;
let result = loop {
match fetch(attempt) {
Ok(body) => break Ok(body),
Err(FetchError::Timeout) => println!("attempt {attempt}: timeout, retrying now"),
Err(FetchError::RateLimited { retry_after_secs }) => {
println!("attempt {attempt}: rate limited, waiting {retry_after_secs}s");
waited += retry_after_secs;
}
Err(e @ FetchError::NotFound) => break Err(e),
}
attempt += 1;
};
println!("{:?} after {attempt} attempts, {waited}s waiting", result);
}attempt 1: timeout, retrying now
attempt 2: rate limited, waiting 30s
Ok("payload") after 3 attempts, 30s waiting12. Downcasting a Box<dyn Error>
When an error has been boxed, downcast_ref::<T>() recovers the concrete type.
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
struct QuotaExceeded {
limit: u32,
}
impl fmt::Display for QuotaExceeded {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "quota of {} requests exceeded", self.limit)
}
}
impl Error for QuotaExceeded {}
fn handle(input: &str, used: u32) -> Result<u32, Box<dyn Error>> {
let n: u32 = input.parse()?;
if used + n > 100 {
return Err(Box::new(QuotaExceeded { limit: 100 }));
}
Ok(used + n)
}
fn main() {
for (input, used) in [("10", 50), ("x", 50), ("80", 50)] {
match handle(input, used) {
Ok(total) => println!("ok, used {total}"),
Err(e) if e.downcast_ref::<QuotaExceeded>().is_some() => println!("429: {e}"),
Err(e) if e.downcast_ref::<ParseIntError>().is_some() => println!("400: bad number ({e})"),
Err(e) => println!("500: {e}"),
}
}
}ok, used 60
400: bad number (invalid digit found in string)
429: quota of 100 requests exceededPrefer enums when you control the error type: matching on variants is checked by the compiler, while downcasting can silently stop matching when types change.
13. std::io::Error and ErrorKind
I/O errors carry an ErrorKind you can match on without depending on OS-specific messages.
use std::fs;
use std::io::ErrorKind;
fn read_config(path: &str) -> Result<String, String> {
match fs::read_to_string(path) {
Ok(text) => Ok(text),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(String::from("# defaults")),
Err(e) => Err(format!("cannot read {path}: {:?}", e.kind())),
}
}
fn main() {
println!("{:?}", read_config("/definitely/missing/app.toml"));
println!("{:?}", read_config("/"));
}Ok("# defaults")
Err("cannot read /: IsADirectory")Reading a missing config file falls back to defaults, while reading a directory is still reported as an error.
14. Creating your own io::Error
Code that implements I/O-like operations can return io::Error with a kind and a custom message.
use std::io;
fn read_header(bytes: &[u8]) -> io::Result<[u8; 4]> {
bytes
.get(..4)
.and_then(|h| h.try_into().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, format!("need 4 header bytes, got {}", bytes.len())))
}
fn main() {
println!("{:?}", read_header(b"PNG\x0d\x0a"));
let err = read_header(b"PN").unwrap_err();
println!("{:?}: {err}", err.kind());
}Ok([80, 78, 71, 13])
UnexpectedEof: need 4 header bytes, got 2io::Result<T> is a type alias for Result<T, io::Error>.
15. A type alias for your crate's Result
Libraries commonly define type Result<T> = std::result::Result<T, MyError>; so every signature stays short.
use std::fmt;
#[derive(Debug)]
enum StoreError {
Full,
Duplicate(String),
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
StoreError::Full => write!(f, "store is full"),
StoreError::Duplicate(k) => write!(f, "key '{k}' already exists"),
}
}
}
type Result<T> = std::result::Result<T, StoreError>;
struct Store {
keys: Vec<String>,
capacity: usize,
}
impl Store {
fn add(&mut self, key: &str) -> Result<usize> {
if self.keys.iter().any(|k| k == key) {
return Err(StoreError::Duplicate(key.to_string()));
}
if self.keys.len() == self.capacity {
return Err(StoreError::Full);
}
self.keys.push(key.to_string());
Ok(self.keys.len())
}
}
fn main() {
let mut s = Store { keys: Vec::new(), capacity: 2 };
for k in ["a", "b", "a", "c"] {
match s.add(k) {
Ok(n) => println!("added {k}, size {n}"),
Err(e) => println!("cannot add {k}: {e}"),
}
}
}added a, size 1
added b, size 2
cannot add a: key 'a' already exists
cannot add c: store is full16. Errors that carry context
Wrapping an error with where it happened (a file, a line, a record ID) makes reports actionable.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct LineError {
line: usize,
content: String,
cause: Box<dyn Error>,
}
impl fmt::Display for LineError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "line {}: '{}'", self.line, self.content)
}
}
impl Error for LineError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(self.cause.as_ref())
}
}
fn parse_amounts(text: &str) -> Result<Vec<u32>, LineError> {
text.lines()
.enumerate()
.map(|(i, line)| {
line.trim().parse::<u32>().map_err(|e| LineError { line: i + 1, content: line.to_string(), cause: Box::new(e) })
})
.collect()
}
fn main() {
println!("{:?}", parse_amounts("10\n20\n30"));
let err = parse_amounts("10\ntwenty\n30").unwrap_err();
println!("{err}, caused by: {}", err.source().unwrap());
}Ok([10, 20, 30])
line 2: 'twenty', caused by: invalid digit found in string17. Collecting every error instead of stopping at the first
Validation forms should report all problems at once. Collect errors into a Vec and return them together.
use std::fmt;
#[derive(Debug)]
enum FieldError {
Required(&'static str),
TooLong { field: &'static str, max: usize },
Invalid { field: &'static str, reason: &'static str },
}
impl fmt::Display for FieldError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
FieldError::Required(field) => write!(f, "{field} is required"),
FieldError::TooLong { field, max } => write!(f, "{field} must be at most {max} characters"),
FieldError::Invalid { field, reason } => write!(f, "{field} {reason}"),
}
}
}
fn validate(name: &str, email: &str, bio: &str) -> Result<(), Vec<FieldError>> {
let mut errors = Vec::new();
if name.trim().is_empty() {
errors.push(FieldError::Required("name"));
}
if !email.contains('@') {
errors.push(FieldError::Invalid { field: "email", reason: "must contain '@'" });
}
if bio.chars().count() > 20 {
errors.push(FieldError::TooLong { field: "bio", max: 20 });
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
fn main() {
println!("{:?}", validate("Ada", "ada@example.com", "mathematician"));
if let Err(errors) = validate(" ", "ada.example.com", "first computer programmer, 1843") {
for e in errors {
println!("- {e}");
}
}
}Ok(())
- name is required
- email must contain '@'
- bio must be at most 20 characters18. Mapping errors to HTTP status codes
At the edge of an application, domain errors become responses. One match in one place decides each status code.
use std::fmt;
#[derive(Debug)]
enum ApiError {
NotFound(String),
Validation(Vec<String>),
Conflict(String),
Internal(String),
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ApiError::NotFound(what) => write!(f, "{what} not found"),
ApiError::Validation(problems) => write!(f, "validation failed: {}", problems.join("; ")),
ApiError::Conflict(what) => write!(f, "{what} already exists"),
ApiError::Internal(_) => write!(f, "internal server error"),
}
}
}
impl ApiError {
fn status(&self) -> u16 {
match self {
ApiError::NotFound(_) => 404,
ApiError::Validation(_) => 422,
ApiError::Conflict(_) => 409,
ApiError::Internal(_) => 500,
}
}
}
fn respond(result: Result<String, ApiError>) -> String {
match result {
Ok(body) => format!("200 {body}"),
Err(e) => {
if let ApiError::Internal(detail) = &e {
eprintln!("log: {detail}"); // details go to logs, not to clients
}
format!("{} {{\"error\": \"{e}\"}}", e.status())
}
}
}
fn main() {
println!("{}", respond(Ok(String::from("{\"id\": 7}"))));
println!("{}", respond(Err(ApiError::NotFound(String::from("user 42")))));
println!("{}", respond(Err(ApiError::Validation(vec![String::from("email invalid"), String::from("age too low")]))));
println!("{}", respond(Err(ApiError::Conflict(String::from("username 'ferris'")))));
println!("{}", respond(Err(ApiError::Internal(String::from("db pool exhausted")))));
}200 {"id": 7}
404 {"error": "user 42 not found"}
422 {"error": "validation failed: email invalid; age too low"}
409 {"error": "username 'ferris' already exists"}
500 {"error": "internal server error"}The internal detail is only written to standard error, so it doesn't appear in the output, and never reaches a client.
19. Send + Sync errors for threads and async
Errors that cross threads must be Send + Sync. The common catch-all type for that is Box<dyn Error + Send + Sync>; you'll need it in Concurrency.
use std::error::Error;
type BoxError = Box<dyn Error + Send + Sync>;
fn parse_all(inputs: &[&str]) -> Result<Vec<i64>, BoxError> {
inputs.iter().map(|s| s.parse::<i64>().map_err(BoxError::from)).collect()
}
fn main() {
println!("{:?}", parse_all(&["1", "2", "3"]));
println!("{}", parse_all(&["1", "x"]).unwrap_err());
}Ok([1, 2, 3])
invalid digit found in string20. Realistic example: a configuration loader
The loader reports which file and which key failed, keeps the underlying cause, and lets the caller decide whether to fall back.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
enum ConfigError {
MissingKey { file: String, key: &'static str },
InvalidValue { file: String, key: &'static str, value: String, cause: Box<dyn Error> },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::MissingKey { file, key } => write!(f, "{file}: missing required key '{key}'"),
ConfigError::InvalidValue { file, key, value, .. } => write!(f, "{file}: invalid value '{value}' for '{key}'"),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ConfigError::InvalidValue { cause, .. } => Some(cause.as_ref()),
ConfigError::MissingKey { .. } => None,
}
}
}
#[derive(Debug)]
struct Config {
port: u16,
timeout_secs: f64,
}
fn get<'a>(text: &'a str, key: &str) -> Option<&'a str> {
text.lines().find_map(|l| l.split_once('=').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim()))
}
fn load(file: &str, text: &str) -> Result<Config, ConfigError> {
let raw_port = get(text, "port").ok_or(ConfigError::MissingKey { file: file.to_string(), key: "port" })?;
let port = raw_port.parse().map_err(|e| ConfigError::InvalidValue {
file: file.to_string(),
key: "port",
value: raw_port.to_string(),
cause: Box::new(e),
})?;
let raw_timeout = get(text, "timeout").unwrap_or("30");
let timeout_secs = raw_timeout.parse().map_err(|e| ConfigError::InvalidValue {
file: file.to_string(),
key: "timeout",
value: raw_timeout.to_string(),
cause: Box::new(e),
})?;
Ok(Config { port, timeout_secs })
}
fn main() {
let files = [("prod.conf", "port = 443\ntimeout = 2.5"), ("dev.conf", "timeout = 10"), ("bad.conf", "port = 99999")];
for (name, text) in files {
match load(name, text) {
Ok(c) => println!("{name}: {:?}", c),
Err(e) => println!("{e}{}", e.source().map(|c| format!(" ({c})")).unwrap_or_default()),
}
}
}prod.conf: Config { port: 443, timeout_secs: 2.5 }
dev.conf: missing required key 'port'
bad.conf: invalid value '99999' for 'port' (number too large to fit in target type)21. Realistic example: an order service with domain errors
use std::fmt;
#[derive(Debug, PartialEq)]
enum OrderError {
EmptyCart,
OutOfStock { sku: String, requested: u32, available: u32 },
PaymentDeclined { reason: String },
}
impl fmt::Display for OrderError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
OrderError::EmptyCart => write!(f, "your cart is empty"),
OrderError::OutOfStock { sku, requested, available } => {
write!(f, "only {available} of {sku} left (you asked for {requested})")
}
OrderError::PaymentDeclined { reason } => write!(f, "payment declined: {reason}"),
}
}
}
impl std::error::Error for OrderError {}
fn place_order(cart: &[(&str, u32)], stock: &[(&str, u32)], card_ok: bool) -> Result<u32, OrderError> {
if cart.is_empty() {
return Err(OrderError::EmptyCart);
}
for &(sku, qty) in cart {
let available = stock.iter().find(|(s, _)| *s == sku).map(|(_, q)| *q).unwrap_or(0);
if qty > available {
return Err(OrderError::OutOfStock { sku: sku.to_string(), requested: qty, available });
}
}
if !card_ok {
return Err(OrderError::PaymentDeclined { reason: String::from("insufficient funds") });
}
Ok(4_812)
}
fn main() {
let stock = [("KB-1", 5), ("MS-2", 1)];
let attempts: [(&[(&str, u32)], bool); 4] = [(&[], true), (&[("KB-1", 2), ("MS-2", 3)], true), (&[("KB-1", 1)], false), (&[("KB-1", 1)], true)];
for (cart, card_ok) in attempts {
match place_order(cart, &stock, card_ok) {
Ok(id) => println!("order #{id} placed"),
Err(e) => println!("sorry, {e}"),
}
}
}sorry, your cart is empty
sorry, only 1 of MS-2 left (you asked for 3)
sorry, payment declined: insufficient funds
order #4812 placed22. Realistic example: an import job that reports all bad rows
The job imports every valid row and returns the failures with line numbers, instead of failing the whole file.
use std::fmt;
#[derive(Debug)]
enum RowError {
WrongFieldCount { found: usize },
BadNumber { field: &'static str, value: String },
}
impl fmt::Display for RowError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RowError::WrongFieldCount { found } => write!(f, "expected 3 fields, found {found}"),
RowError::BadNumber { field, value } => write!(f, "{field} '{value}' is not a number"),
}
}
}
#[derive(Debug)]
struct Product {
sku: String,
price_cents: u32,
stock: u32,
}
fn parse_row(line: &str) -> Result<Product, RowError> {
let fields: Vec<&str> = line.split(',').map(str::trim).collect();
let [sku, price, stock] = fields.as_slice() else {
return Err(RowError::WrongFieldCount { found: fields.len() });
};
let number = |field: &'static str, v: &str| v.parse::<u32>().map_err(|_| RowError::BadNumber { field, value: v.to_string() });
Ok(Product { sku: sku.to_string(), price_cents: number("price", price)?, stock: number("stock", stock)? })
}
fn main() {
let csv = "KB-1, 4999, 10\nMS-2, 19.99, 4\nHD-3, 8999\nWC-4, 2999, lots\nPD-5, 999, 0";
let mut imported = Vec::new();
let mut failures = Vec::new();
for (i, line) in csv.lines().enumerate() {
match parse_row(line) {
Ok(p) => imported.push(p),
Err(e) => failures.push(format!("line {}: {e}", i + 1)),
}
}
println!("imported {:?}", imported.iter().map(|p| &p.sku).collect::<Vec<_>>());
for f in &failures {
println!("skipped {f}");
}
let stock_value: u64 = imported.iter().map(|p| p.price_cents as u64 * p.stock as u64).sum();
println!("stock value {:.2}", stock_value as f64 / 100.0);
}imported ["KB-1", "PD-5"]
skipped line 2: price '19.99' is not a number
skipped line 3: expected 3 fields, found 2
skipped line 4: stock 'lots' is not a number
stock value 499.9023. Realistic example: a run function and a clean exit
Keep main tiny: call run, print the error chain for humans, and exit with a non-zero code. This gives scripts a reliable exit status and users a readable message instead of a Debug dump.
use std::error::Error;
use std::process::ExitCode;
fn run(args: &[&str]) -> Result<u64, Box<dyn Error>> {
let [a, b] = args else {
return Err(format!("expected 2 numbers, got {}", args.len()).into());
};
let a: u64 = a.parse()?;
let b: u64 = b.parse()?;
a.checked_mul(b).ok_or_else(|| "result overflowed".into())
}
fn main() -> ExitCode {
for args in [&["6", "7"][..], &["9"], &["3", "x"]] {
match run(args) {
Ok(v) => println!("{:?} -> {v}", args),
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
}
}
ExitCode::SUCCESS
}["6", "7"] -> 42
error: expected 2 numbers, got 1std::process::ExitCode lets main choose the exit status explicitly. .into() converts a String or &str into a Box<dyn Error>.
24. Realistic example: an error type for a library
A small library exposes one public error type with variants callers can match, a source chain, and conversions for ?, the full pattern this lesson built up.
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
pub enum SemverError {
Empty,
WrongParts(usize),
BadNumber { part: &'static str, source: ParseIntError },
}
impl fmt::Display for SemverError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
SemverError::Empty => write!(f, "version string is empty"),
SemverError::WrongParts(n) => write!(f, "expected 3 parts, found {n}"),
SemverError::BadNumber { part, .. } => write!(f, "{part} version is not a number"),
}
}
}
impl Error for SemverError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
SemverError::BadNumber { source, .. } => Some(source),
_ => None,
}
}
}
pub fn parse(v: &str) -> Result<(u64, u64, u64), SemverError> {
let v = v.trim().trim_start_matches('v');
if v.is_empty() {
return Err(SemverError::Empty);
}
let parts: Vec<&str> = v.split('.').collect();
let [major, minor, patch] = parts[..] else {
return Err(SemverError::WrongParts(parts.len()));
};
let num = |part, s: &str| s.parse().map_err(|source| SemverError::BadNumber { part, source });
Ok((num("major", major)?, num("minor", minor)?, num("patch", patch)?))
}
fn main() {
for input in ["v1.10.3", "", "1.2", "1.x.3"] {
match parse(input) {
Ok(v) => println!("{input:?} -> {:?}", v),
Err(e) => println!("{input:?} -> {e}{}", e.source().map(|s| format!(": {s}")).unwrap_or_default()),
}
}
}"v1.10.3" -> (1, 10, 3)
"" -> version string is empty
"1.2" -> expected 3 parts, found 2
"1.x.3" -> minor version is not a number: invalid digit found in stringThis is exactly the boilerplate the thiserror crate generates for you, as the next lesson shows.
25. Choosing an error strategy
| Situation | Error type |
|---|---|
| Quick script, example, prototype | Box<dyn Error> (or anyhow, next lesson) |
| Application code that only reports errors | Box<dyn Error> / anyhow::Error with context |
| Library, or code whose callers react to specific failures | A custom enum implementing Error (or thiserror) |
| Validation with many problems | Result<T, Vec<FieldError>> |
| Bugs and broken invariants | panic!, expect, assert! |
fn parse_percentage(s: &str) -> Result<u8, String> {
let n: u8 = s.trim_end_matches('%').parse().map_err(|_| format!("'{s}' is not a percentage"))?;
if n > 100 {
return Err(format!("{n}% is over 100%"));
}
Ok(n)
}
fn main() {
let inputs = ["50%", "150%", "abc"];
let results: Vec<Result<u8, String>> = inputs.iter().map(|s| parse_percentage(s)).collect();
println!("{:?}", results);
}[Ok(50), Err("150% is over 100%"), Err("'abc' is not a percentage")]Even this small function would benefit from an enum error once a caller needs to tell "over 100" apart from "not a number".
26. A service error as one enum
A Rust service can model its errors as one enum, and the compiler checks that every caller's match handles every case:
#[derive(Debug)]
enum OrderError {
OutOfStock { sku: &'static str },
Payment { code: u16 },
}
fn describe(e: &OrderError) -> String {
match e {
OrderError::OutOfStock { sku } => format!("{sku} is sold out"),
OrderError::Payment { code } => format!("payment failed with code {code}"),
}
}
fn main() {
for e in [OrderError::OutOfStock { sku: "KB-1" }, OrderError::Payment { code: 402 }] {
println!("{}", describe(&e));
}
}KB-1 is sold out
payment failed with code 402Adding a third variant makes describe stop compiling until it handles the new case.
Summary
- Model failures as an enum with a variant per case, carrying useful data.
- Implement
Displayfor a human message andstd::error::Errorso the type works with the whole ecosystem. - Override
source()to expose the underlying cause, and print chains by walkingsource(). - Implement
From<LowLevelError>so?wraps errors automatically. Box<dyn Error>(plusSend + Syncfor threads) is the catch-all type for applications;downcast_refrecovers concrete types when needed.- Add context (file, line, ID) where errors are created, and map errors to user-facing responses in one place at the edge.
Next: thiserror & anyhow.
