How to create custom error types

Create custom error types in Rust by defining an enum and implementing the Display and Error traits.

How to create custom error types

You're writing a function that loads a configuration file. It might fail because the file doesn't exist. It might fail because the file is there but the JSON is garbage. It might fail because the user lacks read permissions. If you return a plain String error, the caller sees "Error" and has no idea whether to retry, prompt the user for a path, or crash. You need a way to encode the kind of failure so the rest of your program can react intelligently.

Rust solves this with enums. An error type is an enum where each variant represents a distinct failure mode, carrying the data needed to understand or recover from that failure.

The enum pattern

Rust uses enums for this. An enum is a value that can be one of several distinct shapes. For errors, each variant is a specific error case. Think of a doctor's report. "Patient is unwell" is useless. "Flu" and "Broken Leg" tell you exactly what to do. One needs rest and soup; the other needs a cast. Your error enum does the same thing. It carries the tag (the variant) and the data (the details).

The tag lets the caller match on the error and handle cases differently. The data provides context. A FileNotFound variant holds the path. An InvalidFormat variant holds the malformed string. This structure turns error handling from string parsing into type-safe pattern matching.

Minimal implementation

Start with the enum, derive Debug, implement Display, and implement std::error::Error.

use std::fmt;

// Derive Debug for developer logs and panics.
// This gives a machine-readable representation.
#[derive(Debug)]
enum AppError {
    FileNotFound(String),
    InvalidFormat(String),
}

// Display controls what the user sees.
// This is called when you print with `{}`.
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::FileNotFound(path) => write!(f, "File not found: {}", path),
            AppError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
        }
    }
}

// The Error trait is a marker.
// It enables the `?` operator and dynamic error handling.
impl std::error::Error for AppError {}

Display is for humans. Debug is for machines. Error is for the compiler.

What the traits do

The #[derive(Debug)] attribute generates a Debug implementation. You'll see this output in logs, in println!("{:?}", err), and when the program panics. It's the developer-facing view.

The Display trait controls the user-facing message. When you use {} in a format string, Rust calls Display. This is where you craft a readable sentence. The fmt argument is the formatter, and write! writes into it. Returning fmt::Result propagates formatting errors, though those are rare.

The std::error::Error trait is the marker. It tells the compiler "this type is an error." Without it, you cannot use the ? operator to propagate errors. You also cannot convert the error into Box<dyn Error>, which is the standard way to return dynamic errors from functions. The empty impl block is all you need for the marker.

Real-world errors chain together

Real errors often wrap other errors. If your function calls std::fs::read_to_string, that function returns an io::Error. Your custom error should capture that io::Error as the source. This creates a chain. The top-level error describes what your function was doing. The source error describes the root cause.

Use struct variants when you need to hold multiple fields, like a path and a source error.

use std::fmt;
use std::io;

#[derive(Debug)]
enum AppError {
    // Struct variant holds named fields.
    // `source` wraps the underlying io::Error.
    FileNotFound {
        path: String,
        source: io::Error,
    },
    InvalidFormat(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            // Display is high-level for the user.
            // Ignore the source here; it's for debugging.
            AppError::FileNotFound { path, .. } => {
                write!(f, "Could not load config: {}", path)
            }
            AppError::InvalidFormat(msg) => {
                write!(f, "Config format error: {}", msg)
            }
        }
    }
}

impl std::error::Error for AppError {
    // `source` returns the underlying cause.
    // This enables error chaining and root-cause analysis.
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            // Return the io::Error as the source.
            AppError::FileNotFound { source, .. } => Some(source),
            // No underlying cause for this variant.
            AppError::InvalidFormat(_) => None,
        }
    }
}

Chain your errors. The root cause lives in source.

Conversions and the From trait

The ? operator relies on the From trait to convert errors. If your function returns Result<T, AppError> and you call a function that returns Result<U, io::Error>, the ? operator needs to convert io::Error into AppError. That conversion happens via impl From<io::Error> for AppError.

Without the From impl, you must map errors manually with .map_err(). With the From impl, ? does the conversion automatically.

// This impl allows `?` to convert io::Error to AppError.
impl From<io::Error> for AppError {
    fn from(err: io::Error) -> Self {
        AppError::FileNotFound {
            // Hardcoded path for this example.
            // In real code, pass the actual path.
            path: "config.json".to_string(),
            source: err,
        }
    }
}

fn load_config() -> Result<String, AppError> {
    // The `?` operator uses the From impl automatically.
    // No .map_err() needed.
    std::fs::read_to_string("config.json")?
}

Convention aside: The community rarely writes these impls by hand anymore. The thiserror crate generates the enum, the traits, and the From conversions with attributes. Reach for thiserror when you're building a library or a non-trivial application. It eliminates boilerplate and reduces bugs.

Pitfalls and compiler errors

If you forget to implement std::error::Error, the ? operator won't work. The compiler will reject your code with E0277 (trait bound not satisfied) when you try to return the error from a function that expects Result<T, E> where E: Error. You'll also hit issues if you try to convert your error into Box<dyn Error>. The fix is always the empty impl block.

If you return the wrong variant type, you'll get E0308 (mismatched types). This happens when you try to return AppError::FileNotFound but the function signature expects a different error type, or when you mix up tuple and struct variants. Check your enum definition against your return sites.

If you implement Display but forget Debug, you can still print errors with {}, but you lose the ability to debug-print them with {:?}. Tools like logging frameworks and panic handlers rely on Debug. Always derive Debug for error types.

Don't fight the compiler here. If the trait bounds complain, add the missing impl.

When to use custom errors

Use a custom enum when your function can fail in multiple ways and the caller needs to distinguish between them. Use thiserror when you want the same flexibility as a custom enum but without writing the trait implementations by hand. Use anyhow::Result for application entry points and CLI tools where you just need to propagate errors up and print a nice report at the top. Use String for errors only in quick scripts or tests where error handling is a distraction.

Boilerplate is the enemy. Reach for thiserror the moment you write your third error variant.

Where to go next