Skip to content

Handling Multiple Error Types

25 min read

In TypeScript a single try/catch quietly absorbs every kind of failure — a network error, a parse error, your own validation error — because throw is untyped. Rust forces you to decide, up front, how a function that can fail in several different ways should report those failures. This page is about that decision: erasing all the types behind Box<dyn Error>, or aggregating them into one purpose-built enum, and using #[from] so the ? operator does the conversions for you.


When a function calls several fallible operations, each returns its own error type — std::io::Error here, std::num::ParseIntError there, your own validation error somewhere else. A function has a single Result<T, E> return type, so all of those have to collapse into one E. The two idiomatic answers are Box<dyn Error> (type-erase everything behind a trait object — great for applications) and an aggregating enum (one variant per source — great for libraries whose callers must tell the cases apart). The From trait, usually generated by thiserror’s #[from], is the glue that lets ? convert each underlying error into your chosen E automatically.

Note: This page assumes you already know how Result/Option work (Result & Option), how ? propagates and converts errors via From (The ? Operator), and how to define a single custom error type (Custom Errors). Here we focus specifically on combining many error sources. The full thiserror/anyhow API tour lives in anyhow & thiserror.


In TypeScript, a function that reads a value, parses it, and validates it can throw three unrelated things. The signature says nothing about any of them, and the catch block receives unknown:

// A custom domain error alongside built-in ones.
class OutOfRangeError extends Error {
constructor(public port: number) {
super(`port ${port} is outside 1024..=65535`);
this.name = "OutOfRangeError";
}
}
// Can throw: a filesystem error, a TypeError (parse), or OutOfRangeError.
// None of that appears in the return type `number`.
function loadPort(raw: string): number {
const trimmed = raw.trim();
const port = Number(trimmed);
if (!Number.isInteger(port)) {
throw new TypeError(`config value \`${trimmed}\` is not a number`);
}
if (port < 1024) {
throw new OutOfRangeError(port);
}
return port;
}
for (const raw of ["8080", "free", "80"]) {
try {
console.log(`${raw} -> listening on ${loadPort(raw)}`);
} catch (e) {
// We must re-discover the type at runtime with instanceof.
if (e instanceof OutOfRangeError) {
console.log(`${raw} -> out of range: ${e.message}`);
} else if (e instanceof TypeError) {
console.log(`${raw} -> parse: ${e.message}`);
} else {
console.log(`${raw} -> other: ${String(e)}`);
}
}
}

Running this under Node v22 prints:

8080 -> listening on 8080
free -> parse: config value `free` is not a number
80 -> out of range: port 80 is outside 1024..=65535

Two characteristics define the TypeScript approach, and Rust pushes back on both:

  • Failures are invisible in the type. loadPort returns number; the three things it can throw are documented only by reading the body. A caller that forgets the try/catch compiles fine and crashes at runtime.
  • The error type is recovered with instanceof. Because everything is thrown as unknown, you re-narrow it with a chain of instanceof checks — and nothing forces that chain to be exhaustive.

Rust makes the failure set part of the signature and gives you two ways to model “several error sources, one return type.”


Option A — erase every error behind Box<dyn Error>

Section titled “Option A — erase every error behind Box<dyn Error>”

The quickest way to let one function surface many error types is to return Box<dyn Error>: a heap-allocated trait object that can hold any type implementing the Error trait. The ? operator converts each concrete error into the box automatically.

use std::error::Error;
use std::fs;
// Three unrelated failures, one return type:
// - fs::read_to_string -> std::io::Error
// - str::parse -> std::num::ParseIntError
// - our own range check -> a string error
fn load_port(path: &str) -> Result<u16, Box<dyn Error>> {
let text = fs::read_to_string(path)?; // io::Error -> Box<dyn Error>
let port: u16 = text.trim().parse()?; // ParseIntError -> Box<dyn Error>
if port < 1024 {
// &str -> Box<dyn Error> via a standard-library From impl
return Err(format!("port {port} is privileged").into());
}
Ok(port)
}
fn main() {
match load_port("config.txt") {
Ok(port) => println!("listening on {port}"),
Err(e) => eprintln!("startup failed: {e}"),
}
}

With no config.txt present, this prints:

startup failed: No such file or directory (os error 2)

This is the closest Rust gets to TypeScript’s “throw anything.” But notice what the caller loses: a Box<dyn Error> is opaque, so they can report it but cannot cleanly branch on “was it the I/O failure or the range failure?” without a downcast. That trade-off motivates Option B.

Option B — aggregate the sources into one enum

Section titled “Option B — aggregate the sources into one enum”

When callers need to tell the failure modes apart, give them a closed enum with one variant per source. The thiserror crate generates the Display, Error, source(), and From wiring:

use std::num::ParseIntError;
use thiserror::Error;
// One enum that AGGREGATES every way config loading can fail.
// Display, Error, source(), and the two From impls are all generated.
#[derive(Debug, Error)]
enum ConfigError {
#[error("could not read the config file")]
Io(#[from] std::io::Error), // generates From<std::io::Error> + source()
#[error("config value was not a number")]
Parse(#[from] ParseIntError), // generates From<ParseIntError> + source()
#[error("port {0} is outside 1024..=65535")]
OutOfRange(u16), // no #[from]: built by hand, no underlying cause
}
fn load_port(path: &str) -> Result<u16, ConfigError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ConfigError
let port: u16 = text.trim().parse()?; // ParseIntError -> ConfigError
if port < 1024 {
return Err(ConfigError::OutOfRange(port));
}
Ok(port)
}
fn main() {
if let Err(e) = load_port("missing.txt") {
eprintln!("error: {e}");
// thiserror wired up source() automatically for the #[from] variants:
if let Some(cause) = std::error::Error::source(&e) {
eprintln!(" caused by: {cause}");
}
}
}

Output:

error: could not read the config file
caused by: No such file or directory (os error 2)

The signature Result<u16, ConfigError> now tells the caller the exact set of failure modes, and they can match on the variants — the compiler-checked equivalent of the instanceof chain, with exhaustiveness guaranteed.


dyn Error is a trait object: a value whose concrete type is not known at compile time, only that it implements std::error::Error. Because trait objects are unsized, they must live behind a pointer — hence Box<dyn Error>, a pointer to a heap allocation that also carries a vtable. This is type erasure, and it is the part of Rust that most resembles dynamic dispatch in TypeScript. (Trait objects are covered in depth in Section 09: Generics & Traits.)

The reason ? “just works” with it is a single blanket implementation in the standard library, roughly:

// From the standard library (simplified).
impl<E: Error + 'static> From<E> for Box<dyn Error> {
fn from(err: E) -> Box<dyn Error> {
Box::new(err)
}
}

So any error type at all can become a Box<dyn Error>. When ? sees a Result<_, std::io::Error> inside a function returning Result<_, Box<dyn Error>>, it calls this From to box the io::Error. The format!(...).into() line uses a sibling impl (From<String>/From<&str> for Box<dyn Error>) that wraps a plain message string. You get propagation and conversion for free, with zero From impls of your own.

Box<dyn Error> is perfect when the caller will only report the error (log it, return a 500, exit). It is a poor fit when the caller must decide something based on which failure occurred — retry on a timeout, return 404 on not-found, surface a 400 on bad input. Because the concrete type is erased, the only way to recover it is a runtime downcast, which is exactly the brittle instanceof-style narrowing Rust normally lets you avoid.

An aggregating enum keeps the type information. Here is the same ConfigError written by hand, so you can see precisely what #[from] generates:

use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(ParseIntError),
OutOfRange(u16),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(_) => write!(f, "could not read the config file"),
ConfigError::Parse(_) => write!(f, "config value was not a number"),
ConfigError::OutOfRange(p) => write!(f, "port {p} is outside 1024..=65535"),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ConfigError::Io(e) => Some(e),
ConfigError::Parse(e) => Some(e),
ConfigError::OutOfRange(_) => None,
}
}
}
// These two From impls are what let `?` convert automatically.
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::Io(e)
}
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::Parse(e)
}
}
fn load_port(path: &str) -> Result<u16, ConfigError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ConfigError via From
let port: u16 = text.trim().parse()?; // ParseIntError -> ConfigError via From
if port < 1024 {
return Err(ConfigError::OutOfRange(port)); // our own variant, no conversion
}
Ok(port)
}
fn main() {
match load_port("missing.txt") {
Ok(port) => println!("port = {port}"),
Err(e) => {
eprintln!("error: {e}");
if let Some(cause) = e.source() {
eprintln!(" caused by: {cause}");
}
// The caller can also branch on the variant:
match e {
ConfigError::Io(_) => eprintln!(" -> tip: check the path"),
ConfigError::Parse(_) => eprintln!(" -> tip: file must contain only a number"),
ConfigError::OutOfRange(_) => eprintln!(" -> tip: pick a port >= 1024"),
}
}
}
}

Output:

error: could not read the config file
caused by: No such file or directory (os error 2)
-> tip: check the path

That is roughly twenty lines of mechanical boilerplate — two From impls, a source() arm per variant, a Display arm per variant. Writing it once is instructive; writing it for every error type in a real codebase is tedious and error-prone. That is precisely the boilerplate #[from] removes.

In the thiserror enum, two attributes look similar but do different jobs:

  • #[from] does two things at once: it makes that field the source() of the error and generates a From<ThatType> impl for the whole enum. Use it when “I received error X” should be this variant, so that ? can convert X straight into your error with no .map_err(...).
  • #[source] only marks the field as the cause for source(); it does not generate From. Use it when the variant needs additional context alongside the cause (a row number, a key name), so you construct it explicitly with .map_err(...) and there is no sensible automatic conversion.

A variant can have at most one #[from] field, and a #[from] field cannot share the variant with other data — because a From<X> conversion only receives the X, it has nothing to fill the extra fields with. When you need extra context, switch to #[source]:

use std::num::ParseFloatError;
use thiserror::Error;
#[derive(Debug, Error)]
enum RowError {
// #[from]: the io::Error alone fully determines this variant.
#[error("could not read file")]
Read(#[from] std::io::Error),
// #[source]: we attach a row number too, so there is no automatic From;
// we build this variant by hand with .map_err(...).
#[error("row {row}: not a number")]
Price {
row: usize,
#[source]
cause: ParseFloatError,
},
}
fn main() {
let e = RowError::Price {
row: 7,
cause: "x".parse::<f64>().unwrap_err(),
};
println!("{e}");
println!("caused by: {:?}", std::error::Error::source(&e).map(|c| c.to_string()));
}

Output:

row 7: not a number
caused by: Some("invalid float literal")

#[error(transparent)]: forward an error unchanged

Section titled “#[error(transparent)]: forward an error unchanged”

Sometimes a variant is a pure pass-through: it wraps another error and should adopt that error’s message and cause as its own, adding nothing. #[error(transparent)] delegates both Display and source() straight to the inner error:

use thiserror::Error;
#[derive(Debug, Error)]
enum DbError {
#[error("connection refused")]
Connection,
}
#[derive(Debug, Error)]
enum ServiceError {
// transparent: delegate Display and source() straight to the wrapped error.
#[error(transparent)]
Db(#[from] DbError),
#[error("user {0} is not authorized")]
Unauthorized(u64),
}
fn main() {
let e: ServiceError = DbError::Connection.into();
println!("{e}"); // prints DbError's message, not a wrapper's
}

Output:

connection refused

This is the idiomatic way to let a higher-level error enum absorb a lower-level one (often from another module or crate) when there is nothing useful to add at this layer.

Recovering a concrete type from Box<dyn Error>

Section titled “Recovering a concrete type from Box<dyn Error>”

If you chose Box<dyn Error> but still occasionally need to react to a specific error, you recover the concrete type with downcast_ref::<T>() — Rust’s checked-cast analog of instanceof. It returns Some(&T) if the box really holds a T, and None otherwise:

use std::error::Error;
use std::io;
fn run() -> Result<(), Box<dyn Error>> {
// Pretend something failed with a NotFound io error.
Err(Box::new(io::Error::new(io::ErrorKind::NotFound, "file gone")))
}
fn main() {
if let Err(e) = run() {
// You cannot `match` the variants of a boxed trait object directly.
// You must downcast back to the concrete type you suspect.
if let Some(io_err) = e.downcast_ref::<io::Error>() {
if io_err.kind() == io::ErrorKind::NotFound {
eprintln!("recovering: treating missing file as empty input");
return;
}
}
eprintln!("unrecoverable: {e}");
}
}

Output:

recovering: treating missing file as empty input

If you find yourself downcasting often, that is a signal you wanted an enum, not a box.


ConceptTypeScript / JavaScriptRust
Several failure sourcesAll thrown; type invisible in signatureMust collapse into one E in Result<T, E>
”Throw anything”throw any valueBox<dyn Error> (boxed trait object)
Recover the concrete typee instanceof Fooe.downcast_ref::<Foo>()
Closed set of failuresA union of error classes (not enforced at catch)An enum with a variant per source (exhaustive match)
Converting one error into anotherimplicit — you just throwexplicit From impl, applied by ?
Generating that conversionn/a#[from] (via thiserror)
Underlying causeerror.cause (ES2022)source(), wired by #[from]/#[source]
Exhaustivenessnot checkedcompiler-checked on match

The central trade-off: erase vs. aggregate

Section titled “The central trade-off: erase vs. aggregate”
Box<dyn Error>Aggregating enum
BoilerplateAlmost none — ? boxes anythingOne variant + #[from]/#[source] per source
Caller can match on the causeNo (must downcast)Yes, exhaustively
Adding a new error sourceFreeAdd a variant (a deliberate, visible change)
Best forApplication/main code, scripts, prototypesLibrary APIs whose callers must distinguish failures
Carries cause chainYes (via Error::source)Yes (via #[from]/#[source])

The rule of thumb, expanded in Best Practices: applications box, libraries enumerate. An application usually just reports and exits, so erasing the types costs nothing. A library is consumed by code that needs to make decisions, so the precise enum is part of its public contract.

A bare Box<dyn Error> is not Send, so it cannot cross a thread boundary or live in an async task that may move between threads. For anything touching std::thread::spawn, tokio, or a web handler, use the thread-safe alias:

// The form most application code (and anyhow internally) reaches for.
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;

This is covered in detail in The Error Trait; just remember the + Send + Sync when threads or async are involved.


Pitfall 1: two #[from] variants for the same source type

Section titled “Pitfall 1: two #[from] variants for the same source type”

#[from] generates a From<T> impl for the whole enum. If two variants both claim #[from] for the same T, you get two conflicting From<T> impls — which Rust forbids:

use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
enum ParseError {
#[error("bad width")]
Width(#[from] ParseIntError),
#[error("bad height")]
Height(#[from] ParseIntError), // does not compile (error[E0119])
}
fn main() {}

Real compiler error (cargo build):

error[E0119]: conflicting implementations of trait `From<ParseIntError>` for type `ParseError`
--> src/main.rs:9:14
|
7 | Width(#[from] ParseIntError),
| ---- first implementation here
8 | #[error("bad height")]
9 | Height(#[from] ParseIntError),
| ^^^^ conflicting implementation for `ParseError`

Fix: at most one variant may auto-convert from a given type. The variant that needs to distinguish which field failed should use #[source] (no From) and be constructed explicitly with .map_err(...) so you can record Width vs Height yourself.

Pitfall 2: trying to match the variants of a Box<dyn Error>

Section titled “Pitfall 2: trying to match the variants of a Box<dyn Error>”

A trait object has no visible variants or fields, so you cannot pattern-match its concrete shape:

use std::error::Error;
fn do_work() -> Result<(), Box<dyn Error>> {
Err("nope".into())
}
fn main() {
if let Err(e) = do_work() {
// does not compile (error[E0308]): a trait object has no variants to match.
match e {
std::io::Error { .. } => println!("io"),
_ => println!("other"),
}
}
}

Real compiler error (cargo build, abridged):

error[E0308]: mismatched types
--> src/main.rs:11:13
|
10 | match e {
| - this expression has type `Box<dyn std::error::Error>`
11 | std::io::Error { .. } => println!("io"),
| ^^^^^^^^^^^^^^^^^^^^^ expected `Box<dyn Error>`, found `Error`

Fix: either downcast_ref::<std::io::Error>() to recover the concrete type, or — if branching is a recurring need — switch the return type to an aggregating enum so the variants are first-class.

Pitfall 3: a custom enum missing a From for an error you ?

Section titled “Pitfall 3: a custom enum missing a From for an error you ?”

If you write an aggregating enum by hand and forget the From impl (or the #[from] attribute), ? cannot convert that source into your enum, and the code won’t compile. The ?-operator page covers the exact error[E0277]: ?couldn't convert the error... message (The ? Operator → Pitfall 3). The lesson for aggregation: every error source you intend to ?-propagate needs a #[from] variant (or a manual From). Sources you build with extra context via .map_err(...) use #[source] instead and need no From.

Pitfall 4: re-printing the cause in every layer’s message

Section titled “Pitfall 4: re-printing the cause in every layer’s message”

When you aggregate with #[from]/#[source], the underlying error is already reachable through source(). If each variant’s #[error("...")] also embeds the inner message (e.g. #[error("io failed: {0}")] on a #[from] field), a chain printer or anyhow will print the cause twice. Keep each layer’s message to its own concern and let source() carry the rest. (See The Error Trait for the cause-chain reporting pattern.)


  • Applications box; libraries enumerate. Reach for Box<dyn Error + Send + Sync> (or anyhow::Error) in binaries and main, where you only report. Reach for a thiserror enum in libraries, where callers must distinguish failures. This split is the single most useful heuristic for multi-error code.
  • Let #[from] drive your ? ergonomics. Give each error source a #[from] variant so ? converts automatically with no .map_err(...). It keeps the happy path readable top to bottom.
  • Use #[source] (not #[from]) when a variant carries extra context. A row number, a file path, or a key name that accompanies the cause means there is no sensible automatic From; construct the variant with .map_err(...) and mark the cause #[source].
  • Use #[error(transparent)] to absorb another error wholesale. When one layer wraps another layer’s error and has nothing to add, transparent forwards Display and source() and keeps the chain clean.
  • Add + Send + Sync to boxed errors used with threads or async. Box<dyn Error + Send + Sync + 'static> is the safe default for anything concurrent; a bare Box<dyn Error> will not cross a thread boundary.
  • Don’t reach for downcast as a habit. Occasional downcasting from a box is fine; frequent downcasting means you actually wanted an enum. Let the type system carry the distinction instead of re-discovering it at runtime.
  • Prefer thiserror over hand-rolled From/source(). The hand-written version exists in this page to show what is generated; in real code, derive it. Reserve manual impls for the rare case the derive can’t express.

A price-list import pipeline. It reads a file (an io::Error source), parses each row’s price (a ParseFloatError source), and validates the row shape (its own logic). All three collapse into one ImportError enum: the I/O failure uses #[from] so ? boxes it automatically, while the row-level errors use #[source] because they carry a row number. A category() method shows how a caller (an HTTP layer, say) can branch on the aggregated error to choose a status code.

use std::num::ParseFloatError;
use thiserror::Error;
/// Everything that can go wrong while importing a price list.
/// Three distinct sources are aggregated behind one type.
#[derive(Debug, Error)]
enum ImportError {
/// Reading the file failed (permissions, missing, etc.).
#[error("could not read import file")]
Read(#[from] std::io::Error),
/// A price column was not a valid number.
#[error("row {row}: price `{raw}` is not a number")]
Price {
row: usize,
raw: String,
// #[source] keeps the cause chain without generating a From impl.
#[source]
cause: ParseFloatError,
},
/// The row did not have the two expected fields.
#[error("row {row}: expected `name,price`, got `{line}`")]
Malformed { row: usize, line: String },
}
/// A coarse category an HTTP layer or a logger might switch on.
#[derive(Debug, PartialEq)]
enum Category {
Infrastructure, // retryable / our fault -> 5xx
BadInput, // the user's fault -> 4xx
}
impl ImportError {
fn category(&self) -> Category {
match self {
ImportError::Read(_) => Category::Infrastructure,
ImportError::Price { .. } | ImportError::Malformed { .. } => Category::BadInput,
}
}
}
struct Item {
name: String,
price: f64,
}
fn parse_rows(contents: &str) -> Result<Vec<Item>, ImportError> {
let mut items = Vec::new();
for (i, line) in contents.lines().enumerate() {
let row = i + 1;
// Malformed: no automatic From, so build it explicitly.
let (name, raw_price) = line.split_once(',').ok_or_else(|| ImportError::Malformed {
row,
line: line.to_string(),
})?;
// Price: attach the row + raw value, mark the parse error as #[source].
let price = raw_price
.trim()
.parse::<f64>()
.map_err(|cause| ImportError::Price {
row,
raw: raw_price.trim().to_string(),
cause,
})?;
items.push(Item {
name: name.to_string(),
price,
});
}
Ok(items)
}
fn import(path: &str) -> Result<Vec<Item>, ImportError> {
// `?` converts the io::Error into ImportError::Read automatically (via #[from]).
let contents = std::fs::read_to_string(path)?;
parse_rows(&contents)
}
fn main() {
// Drive parse_rows directly so the example is deterministic (no real file).
let good = "widget,9.99\ngadget,19.50";
let bad_price = "widget,free";
let malformed = "just-a-name";
for (label, data) in [("good", good), ("bad_price", bad_price), ("malformed", malformed)] {
println!("--- {label} ---");
match parse_rows(data) {
Ok(items) => {
for item in items {
println!(" {} = {:.2}", item.name, item.price);
}
}
Err(e) => {
println!(" error [{:?}]: {e}", e.category());
// Walk the source chain for the underlying cause, if any.
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
println!(" caused by: {cause}");
source = cause.source();
}
}
}
}
// `import` exists to show #[from] in action with the ? operator.
let _ = import("does-not-exist.csv").map_err(|e| {
println!("--- import (real file) ---");
println!(" error [{:?}]: {e}", e.category());
});
}

Real output:

--- good ---
widget = 9.99
gadget = 19.50
--- bad_price ---
error [BadInput]: row 1: price `free` is not a number
caused by: invalid float literal
--- malformed ---
error [BadInput]: row 1: expected `name,price`, got `just-a-name`
--- import (real file) ---
error [Infrastructure]: could not read import file

One enum, three sources, two attribute styles (#[from] for the context-free I/O error, #[source] for the row errors that carry extra data), and a category() method that turns the aggregated error into an actionable decision. That is the multi-error pattern most production Rust services use.

Tip: In a real web service you would impl From<ImportError> for StatusCode (or implement a framework trait like axum’s IntoResponse) so the handler can return the error directly and the framework maps category() to a status. The principle is the same: aggregate into one enum, then translate at the boundary.


Related sections in this guide:


Difficulty: Beginner

Objective: Aggregate two error sources into one hand-written enum.

Instructions: Define an enum CliError with variants Io(std::io::Error) and Parse(ParseIntError). Give it #[derive(Debug)], an impl Display, an impl Error whose source() returns the wrapped error, and the two From impls that let ? convert into it. Then write doubled(raw: &str) -> Result<i64, CliError> that parses raw and returns it times two.

use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum CliError {
Io(std::io::Error),
Parse(ParseIntError),
}
// TODO: impl Display, impl Error (with source()), and two From impls.
fn doubled(raw: &str) -> Result<i64, CliError> {
let n: i64 = raw.trim().parse()?; // needs From<ParseIntError> for CliError
Ok(n * 2)
}
fn main() {
for input in ["21", "oops"] {
match doubled(input) {
Ok(n) => println!("{input} -> {n}"),
Err(e) => println!("{input} -> error: {e}"),
}
}
}
Solution
use std::error::Error;
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum CliError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::Io(_) => write!(f, "failed to read input"),
CliError::Parse(_) => write!(f, "input was not a valid integer"),
}
}
}
impl Error for CliError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
CliError::Io(e) => Some(e),
CliError::Parse(e) => Some(e),
}
}
}
impl From<std::io::Error> for CliError {
fn from(e: std::io::Error) -> Self {
CliError::Io(e)
}
}
impl From<ParseIntError> for CliError {
fn from(e: ParseIntError) -> Self {
CliError::Parse(e)
}
}
fn doubled(raw: &str) -> Result<i64, CliError> {
let n: i64 = raw.trim().parse()?; // ParseIntError -> CliError
Ok(n * 2)
}
fn main() {
for input in ["21", "oops"] {
match doubled(input) {
Ok(n) => println!("{input} -> {n}"),
Err(e) => println!("{input} -> error: {e}"),
}
}
}

Output:

21 -> 42
oops -> error: input was not a valid integer

Difficulty: Intermediate

Objective: Replace the hand-written boilerplate with thiserror and #[from].

Instructions: Rewrite Exercise 1’s CliError using #[derive(Debug, thiserror::Error)]. Use #[error("...")] for each variant’s message and #[from] on each wrapped field so the From impls and source() are generated. Keep doubled identical, and have main also print the underlying cause via source(). (Run cargo add thiserror first.)

Solution
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
enum CliError {
#[error("failed to read input")]
Io(#[from] std::io::Error),
#[error("input was not a valid integer")]
Parse(#[from] ParseIntError),
}
fn doubled(raw: &str) -> Result<i64, CliError> {
let n: i64 = raw.trim().parse()?;
Ok(n * 2)
}
fn main() {
for input in ["21", "oops"] {
match doubled(input) {
Ok(n) => println!("{input} -> {n}"),
Err(e) => {
print!("{input} -> error: {e}");
if let Some(cause) = std::error::Error::source(&e) {
print!(" (caused by: {cause})");
}
println!();
}
}
}
}

Output:

21 -> 42
oops -> error: input was not a valid integer (caused by: invalid digit found in string)

The whole impl Display, impl Error, and two From blocks from Exercise 1 collapse into four attribute lines.

Difficulty: Advanced

Objective: Use Box<dyn Error> to surface several error types, then downcast to make a decision.

Instructions: Define two custom error types, RateLimited { retry_after_secs: u64 } and NotFound, each implementing Debug + Display + Error. Write fetch(kind: &str) -> Result<String, Box<dyn Error>> that returns Ok for "ok", a boxed RateLimited for "rate", and a boxed NotFound otherwise. Then write should_retry(err: &(dyn Error + 'static)) -> Option<u64> that uses downcast_ref to return the retry delay only when the boxed error is a RateLimited. In main, drive all three cases and print whether each will be retried.

Solution
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct RateLimited {
retry_after_secs: u64,
}
impl fmt::Display for RateLimited {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "rate limited; retry after {}s", self.retry_after_secs)
}
}
impl Error for RateLimited {}
#[derive(Debug)]
struct NotFound;
impl fmt::Display for NotFound {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "resource not found")
}
}
impl Error for NotFound {}
fn fetch(kind: &str) -> Result<String, Box<dyn Error>> {
match kind {
"ok" => Ok("payload".to_string()),
"rate" => Err(Box::new(RateLimited { retry_after_secs: 30 })),
_ => Err(Box::new(NotFound)),
}
}
/// Decide whether to retry based on the concrete error behind the box.
fn should_retry(err: &(dyn Error + 'static)) -> Option<u64> {
err.downcast_ref::<RateLimited>().map(|e| e.retry_after_secs)
}
fn main() {
for kind in ["ok", "rate", "missing"] {
match fetch(kind) {
Ok(body) => println!("{kind}: got {body}"),
Err(e) => match should_retry(e.as_ref()) {
Some(secs) => println!("{kind}: will retry in {secs}s"),
None => println!("{kind}: giving up: {e}"),
},
}
}
}

Output:

ok: got payload
rate: will retry in 30s
missing: giving up: resource not found

Note: e.as_ref() turns the Box<dyn Error> into the &(dyn Error + 'static) that downcast_ref needs. If you found yourself doing this for many error types, that is the signal to switch from Box<dyn Error> to an aggregating enum.