Skip to content

← Errors Are Values step 18 of 24

Medium Primitives

Boxed errors and downcasting

A helper hands you back errors of two completely different types, both erased behind Box<dyn Error>. Recover the distinction.

fn read(input: &str) -> Result<i32, Box<dyn Error>>
pub fn triage(inputs: Vec<String>) -> Vec<String>

read parses the input as i32 and then checks it is in 0..=100. A non-numeric input fails with std’s ParseIntError; an out-of-range one fails with your own RangeError.

triage maps each input to:

  • "ok:{n}" on success,
  • "range" if the error is a RangeError,
  • "parse" if it is a ParseIntError,
  • "other" for anything else.

Read the starter’s E0782 first

error[E0782]: expected a type, found a trait
   |
   | fn read(input: &str) -> Result<i32, Error> {
   |                                     ^^^^^

In edition 2015 you could write a bare trait name where a type was expected and Rust would quietly read it as a trait object. That was a mistake — it made Box<Error> and Box<SomeStruct> look identical while behaving completely differently — so dyn became mandatory, and in edition 2024 the bare form is a hard error, not a warning.

But dyn Error alone will not compile either, and the reason is worth understanding rather than memorising. dyn Error is a type whose size is not known at compile time: it could be a one-byte struct or a hundred-byte one, depending on which error is in there at runtime. Rust needs a size to lay out a Result, so an unsized type has to sit behind a pointer:

Box<dyn Error>       // owned, heap
&dyn Error           // borrowed

Box<dyn Error> it is.

Why ? “just works” on it

let n: i32 = input.trim().parse()?;

parse gives Result<i32, ParseIntError>; the function returns Result<i32, Box<dyn Error>>; and ? converts because std provides

impl<E: Error + 'static> From<E> for Box<dyn Error>

Recall from the previous item that you cannot write that blanket impl for your own enum — E0119, conflicting with impl<T> From<T> for T. Box<dyn Error> gets away with it precisely because it is a different type from every error it swallows. That is the trade in one sentence: you give up the concrete type, and in exchange ? accepts everything.

There is even an impl for &str and String, so Err("something went wrong")? compiles in a function returning Box<dyn Error>. Convenient, and a stringly-typed contract if you lean on it.

Getting the type back: downcast_ref

if let Some(r) = e.downcast_ref::<RangeError>() {
    // `r` is a `&RangeError` — full access to its fields
}

downcast_ref::<T>() returns Option<&T>: Some if the erased value really is a T, None otherwise. It is a runtime type test, backed by TypeId, which is why it requires T: 'static — a type that borrows cannot have a stable identity.

Three shapes exist: downcast_ref (borrow), downcast_mut (mutable borrow), and downcast (consume the Box, returning Result<Box<T>, Box<dyn Error>> so you get the box back if you guessed wrong).

Note the cost. Distinguishing two error kinds took two runtime type tests, and nothing tells you when you have covered them all — add a third error type to read and this match still compiles, silently routing it to "other". With an enum, the compiler would have stopped you. That is the real tradeoff between Box<dyn Error> and an error enum, and no amount of style preference changes it.

The gate denies clippy::unwrap_used here so you cannot paper over the Option that downcast_ref returns.

Notes

  • Box<dyn Error> is not Send. It cannot cross a thread::spawn boundary, and learners who meet that for the first time inside a threading exercise usually blame threads. The fix is Box<dyn Error + Send + Sync + 'static>, which is also what anyhow::Error uses internally.
  • Match guards let you write this as one matchErr(e) if e.downcast_ref::<RangeError>().is_some() => … — with the arms tried in order. An if let chain works just as well.
  • borrowed_box is the lint for &Box<dyn Error> in a signature: take &dyn Error instead, one less indirection and a more general API.
  • 3.5 and 9999999999 are both ParseIntError (wrong shape and overflow), so one downcast catches both.

Remember the grade is compile + tests + clippy -D warnings.