Custom error types

An error type is an enum of the ways an operation can fail, with a message for each variant. The caller can match the variants and react differently, which a string cannot express. The standard traits Display and Error make the type work with printing and with the ? operator.

This lesson builds one error type, gives it both traits, and adds the From conversion that lets a lower layer fail into it. The last section is the error for a main that returns a custom error without the Debug the runtime needs.

An enum of errors

The enum below has a unit variant and a variant with a number. A unit variant is a case with no data, and the tuple variant carries the size, which is what the message needs.

The program builds both variants and prints them through Display. The two messages are the ones the implementation writes.

use std::fmt; enum LoadError { Missing, TooLarge(u64), } impl fmt::Display for LoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { LoadError::Missing => write!(f, "the file is missing"), LoadError::TooLarge(size) => write!(f, "the file is too large: {size} bytes"), } } } fn main() { let errors = [LoadError::Missing, LoadError::TooLarge(9000)]; for error in &errors { println!("{error}"); } }

rustc --edition 2024 enum_err.rs
./enum_err

the file is missing
the file is too large: 9000 bytes

A caller matches the variants, so a new variant is a compile error at every match that ignores it. The lesson on enums explains why that is the point.

Display and Error

Display gives the message and Error marks the type as an error, with a default source that returns None. The derive on the struct supplies Debug, which the Error trait requires.

The program prints the message and asks whether the error has a source. The answer is true, because the default source is empty.

use std::error::Error; use std::fmt; #[derive(Debug)] struct ConfigError { key: String, } impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "missing key {}", self.key) } } impl Error for ConfigError {} fn main() { let error = ConfigError { key: String::from("port") }; println!("{error}"); println!("{:?}", error.source().is_none()); }

rustc --edition 2024 error_trait.rs
./error_trait

missing key port
true

The Error trait is what a boxed Box<dyn Error> needs, which the next lesson uses. The source chain is the part that keeps the original cause reachable.

From for the layer above

A From<ParseIntError> implementation lets the question mark convert the lower error into the application error. The body usually wraps the original, so the cause survives.

The program parses two strings through the wrapper. The good one prints Ok, the bad one prints the wrapped parse message.

use std::num::ParseIntError; #[derive(Debug)] enum AppError { Parse(ParseIntError), } impl From<ParseIntError> for AppError { fn from(error: ParseIntError) -> Self { AppError::Parse(error) } } fn parse(text: &str) -> Result<i32, AppError> { let value: i32 = text.parse()?; Ok(value) } fn main() { println!("{:?}", parse("42")); match parse("x") { Ok(value) => println!("ok {value}"), Err(AppError::Parse(error)) => println!("error: {error}"), } }

rustc --edition 2024 from.rs
./from

Ok(42)
error: invalid digit found in string

The conversion is why the question mark works across layers without a match at every step. The lesson on error propagation builds the full chain.

Matching the variants

A caller that needs to react differently matches the enum. The match is exhaustive, so a new variant forces a decision, and the data on the variant is available to the arm.

The program describes a full queue and a timeout with its number. The two messages differ because the arms do.

enum JobError { QueueFull, Timeout(u64), } fn describe(error: &JobError) -> String { match error { JobError::QueueFull => String::from("queue full"), JobError::Timeout(seconds) => format!("timed out after {seconds}s"), } } fn main() { println!("{}", describe(&JobError::QueueFull)); println!("{}", describe(&JobError::Timeout(30))); }

rustc --edition 2024 match_variants.rs
./match_variants

queue full
timed out after 30s

A caller that does not care about the difference prints through Display instead of matching. Both are valid, and the type supports both.

An error that is not Debug

A main that returns Result<(), E> needs E to implement Debug, because the runtime prints the error when the value is Err. Without the derive, the return type itself is the error.

The program returns a custom error from main without Debug. The compiler names the trait and offers the derive, which is one line.

enum MyError { Bad, } fn main() -> Result<(), MyError> { Err(MyError::Bad) }

rustc --edition 2024 debug_err.rs

error[E0277]: `MyError` doesn't implement `Debug`
--> debug_err.rs:5:14
|
5 | fn main() -> Result<(), MyError> {
| ^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `MyError`
|
= note: add `#[derive(Debug)]` to `MyError` or manually `impl Debug for MyError`
= note: required for `Result<(), MyError>` to implement `Termination`
help: consider annotating `MyError` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | enum MyError {
|

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0277`.

The same requirement comes from Termination, which is the trait that turns a main return value into a status. A deriving struct has it for free.

Exercises

  1. Add a third variant to the load error with its own message.
  2. Give the error type a From for a missing file error and use the question mark.
  3. Match the variants in main and print a different line for each.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why is an enum better than a string for an error?

    Answer

    The caller can match the variants

    The variants are a type, so a caller can react to each case and the compiler checks the match.

  2. Which trait turns an error into a printable message?

    Answer

    Display

    Display writes the user-facing message, while Debug is for the programmer.

  3. What does implementing From<ParseIntError> enable?

    Answer

    The question mark to convert the parse error into the app error

    The question mark uses From to convert the error type of the layer below.

  4. Why does a main returning Result need Debug on the error?

    Answer

    The runtime prints the Err value with the debug form

    The Termination implementation prints the error, and it needs Debug to do that.

  5. What does this program print? use std::fmt; enum LoadError { Missing, TooLarge(u64), } impl fmt::Display for LoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { LoadError::Missing => write!(f, "missing"), LoadError::TooLarge(size) => write!(f, "too large: {size}"), } } } fn main() { println!("{}", LoadError::TooLarge(100)); }

    Answer

    too large: 100

    The tuple variant writes its number into the message.

  6. What does this program print? use std::error::Error; use std::fmt; #[derive(Debug)] struct E; impl fmt::Display for E { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "bad") } } impl Error for E {} fn main() { let error = E; println!("{} {:?}", error, error.source().is_none()); }

    Answer

    bad true

    Display prints bad, and the default source is empty.

  7. What does this program print? use std::num::ParseIntError; #[derive(Debug)] enum AppError { Parse(ParseIntError), } impl From<ParseIntError> for AppError { fn from(error: ParseIntError) -> Self { AppError::Parse(error) } } fn parse(text: &str) -> Result<i32, AppError> { let value: i32 = text.parse()?; Ok(value) } fn main() { println!("{:?}", parse("42")); }

    Answer

    Ok(42)

    The parse succeeds, and the question mark does not need the conversion.

  8. What does this program print? enum JobError { QueueFull, Timeout(u64), } fn describe(error: &JobError) -> String { match error { JobError::QueueFull => String::from("queue full"), JobError::Timeout(seconds) => format!("timed out after {seconds}s"), } } fn main() { println!("{}", describe(&JobError::Timeout(30))); }

    Answer

    timed out after 30s

    The timeout arm formats the number into the message.

  9. What is the first line of the compiler output for this program? enum MyError { Bad, } fn main() -> Result<(), MyError> { Err(MyError::Bad) }

    Answer

    error[E0277]: MyError doesn't implement Debug

    The runtime prints the error, which needs Debug on the type.

  10. What does this program print? use std::fmt; struct E; impl fmt::Display for E { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "custom") } } fn main() { let errors = [E, E]; for error in &errors { println!("{error}"); } }

    Answer

    custom / custom

    The array has two values, and each prints through the same Display implementation.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev