Rust Custom Error Types

Strings and library errors work for small scripts. Larger programs need errors that describe exactly what failed, so callers can respond differently to each case. A custom error type acts like a hospital triage tag: it names the problem, and each department knows what to do with it.

Defining an Error Enum

An enum lists every failure the program can produce. Each variant may carry extra details.

#[derive(Debug)]
enum AppError {
    EmptyInput,
    NotANumber(String),
    TooLarge { value: i32, limit: i32 },
}
            AppError
         /      |       \
 EmptyInput  NotANumber  TooLarge
   (none)     (text)    (value, limit)

Making Errors Printable with Display

The Display trait controls how an error appears when printed with {}. Write messages that a user can understand.

use std::fmt;

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::EmptyInput => write!(f, "No input was given"),
            AppError::NotANumber(text) => write!(f, "'{text}' is not a number"),
            AppError::TooLarge { value, limit } =>
                write!(f, "{value} is larger than the limit {limit}"),
        }
    }
}

Joining the Error Trait

The standard Error trait marks a type as a proper error. An empty implementation is enough once Debug and Display exist.

impl std::error::Error for AppError {}

The marker unlocks compatibility with Box<dyn Error> and many third-party tools.

Using the Error

fn check(text: &str) -> Result<i32, AppError> {
    if text.trim().is_empty() {
        return Err(AppError::EmptyInput);
    }
    let value: i32 = text
        .trim()
        .parse()
        .map_err(|_| AppError::NotANumber(text.to_string()))?;
    if value > 100 {
        return Err(AppError::TooLarge { value, limit: 100 });
    }
    Ok(value)
}

fn main() {
    for input in ["42", "", "abc", "500"] {
        match check(input) {
            Ok(v) => println!("Accepted {v}"),
            Err(e) => println!("Rejected: {e}"),
        }
    }
}
Accepted 42
Rejected: No input was given
Rejected: 'abc' is not a number
Rejected: 500 is larger than the limit 100

Automatic Conversion with From

The question mark operator converts errors through From. Implement From for each outside error you want to absorb, and the operator handles the translation.

use std::num::ParseIntError;

#[derive(Debug)]
enum ReadError {
    Parse(ParseIntError),
    Negative(i32),
}

impl From<ParseIntError> for ReadError {
    fn from(e: ParseIntError) -> Self {
        ReadError::Parse(e)
    }
}

fn read_positive(text: &str) -> Result<i32, ReadError> {
    let n: i32 = text.parse()?;   // ParseIntError becomes ReadError
    if n < 0 {
        return Err(ReadError::Negative(n));
    }
    Ok(n)
}
 text.parse() --Err(ParseIntError)--> From --> ReadError::Parse

Wrapping Several Sources

Real programs combine file errors, parse errors, and rule violations. One enum with a variant per source keeps everything in one place.

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    Missing(String),
}

Design Tips

  • Name variants after the problem, not the function that failed.
  • Store only data that helps the caller decide what to do.
  • Write messages in plain language without technical jargon.
  • Keep one error enum per module or per application area.

Shortcut with Helper Crates

Writing Display and From code repeatedly becomes tedious. The thiserror crate generates both from short attributes, and the anyhow crate offers a flexible error type for application code. Learn the manual way first, because it explains what those tools produce.

Choosing a Strategy

SituationSuggested Approach
Quick scriptBox<dyn Error>
Library other people useCustom enum
Application with many error sourcesCustom enum or anyhow

Common Mistakes

Storing too much detail inside variants makes errors awkward to build and match. Keep only the facts a caller needs.

Forgetting the Debug derive stops the compiler from accepting the type as an error. Both Debug and Display are required for the Error trait.

Writing vague messages such as "failed" frustrates users. A strong message names the problem and, when possible, hints at the fix.

Practice Ideas

  • Create a LoginError enum with variants for a wrong password, a locked account, and an unknown user.
  • Implement Display so each variant prints a friendly sentence.
  • Add a From implementation for ParseIntError and use ? inside a function.

Testing Errors

Custom enums make tests precise. A test can check that a function returns Err(AppError::EmptyInput) for blank text, which proves the exact failure path instead of any failure at all.

Documenting Errors

Public functions should document which errors they return. A short section in the doc comment lists each variant and the situation that triggers it. Callers then write correct handling code without reading the function body.

Key Points

  • A custom enum names each failure the program can produce.
  • Implement Display for readable messages and Error for compatibility.
  • Implement From so ? converts outside errors automatically.
  • Helper crates reduce repeated code once the basics are clear.