Why build a custom error type?

Rust encourages you to handle failures at the type level. Instead of throwing exceptions, functions return Result<T, E>, where E describes what went wrong. For small programs, a string may be enough. For real software, it usually is not.

A structured error type helps you:

  • distinguish between different failure categories
  • preserve the original cause of an error
  • attach context that helps with debugging
  • present user-friendly messages at the application boundary

This is especially useful when your code interacts with files, parsing, networking, or external services. A good error design keeps your internal code precise while still allowing your CLI or API layer to print a clean message.

What we are building

We will build a small library for loading and validating a configuration file. The library will support three error cases:

  • the file cannot be read
  • the file contents are invalid UTF-8 or malformed
  • a required setting is missing or invalid

This is a realistic starting point because configuration loading appears in many applications, and it naturally involves I/O, parsing, and validation.

Project setup

Create a new library crate:

cargo new config_errors --lib
cd config_errors

Your src/lib.rs will contain the error type, parsing logic, and tests. We will keep dependencies minimal and use only the standard library.

Designing the error type

A good Rust error type should be:

  • specific enough to be useful
  • easy to match on
  • able to preserve source errors
  • displayable for end users

Here is a simple design using an enum:

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

#[derive(Debug)]
pub enum ConfigError {
    Io(io::Error),
    Parse { line: usize, message: String },
    MissingField(&'static str),
    InvalidValue { field: &'static str, value: String },
}

This enum gives us four distinct failure modes. The Io variant wraps a standard library error, while the others capture domain-specific problems.

Why use an enum instead of String?

A string tells you what happened, but not what kind of failure it was. With an enum, callers can decide whether to retry, report a validation issue, or stop immediately.

For example:

  • ConfigError::Io might be recoverable if the file path is wrong
  • ConfigError::MissingField is a user input problem
  • ConfigError::Parse indicates malformed content that needs correction

That distinction becomes valuable as your project grows.

Implementing Display and Error

To integrate with Rust’s error ecosystem, implement std::fmt::Display and std::error::Error.

use std::error::Error;
use std::fmt;
use std::io;

#[derive(Debug)]
pub enum ConfigError {
    Io(io::Error),
    Parse { line: usize, message: String },
    MissingField(&'static str),
    InvalidValue { field: &'static str, value: String },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(err) => write!(f, "I/O error: {}", err),
            ConfigError::Parse { line, message } => {
                write!(f, "parse error on line {}: {}", line, message)
            }
            ConfigError::MissingField(field) => write!(f, "missing required field: {}", field),
            ConfigError::InvalidValue { field, value } => {
                write!(f, "invalid value for {}: {}", field, value)
            }
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ConfigError::Io(err) => Some(err),
            _ => None,
        }
    }
}

The source() method is important because it preserves the underlying cause for wrapped errors. This helps debugging tools and logs show the full chain of failure.

Parsing a simple configuration format

Let’s define a tiny config format with key-value pairs:

name=demo
port=8080

We will parse it into a struct:

#[derive(Debug, PartialEq)]
pub struct Config {
    pub name: String,
    pub port: u16,
}

Now implement a parser that validates the input and returns Result<Config, ConfigError>.

pub fn parse_config(input: &str) -> Result<Config, ConfigError> {
    let mut name: Option<String> = None;
    let mut port: Option<u16> = None;

    for (index, raw_line) in input.lines().enumerate() {
        let line_no = index + 1;
        let line = raw_line.trim();

        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        let (key, value) = line
            .split_once('=')
            .ok_or_else(|| ConfigError::Parse {
                line: line_no,
                message: "expected key=value pair".to_string(),
            })?;

        let key = key.trim();
        let value = value.trim();

        match key {
            "name" => {
                if value.is_empty() {
                    return Err(ConfigError::InvalidValue {
                        field: "name",
                        value: value.to_string(),
                    });
                }
                name = Some(value.to_string());
            }
            "port" => {
                let parsed = value.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
                    field: "port",
                    value: value.to_string(),
                })?;
                port = Some(parsed);
            }
            other => {
                return Err(ConfigError::Parse {
                    line: line_no,
                    message: format!("unknown key '{}'", other),
                });
            }
        }
    }

    let name = name.ok_or(ConfigError::MissingField("name"))?;
    let port = port.ok_or(ConfigError::MissingField("port"))?;

    Ok(Config { name, port })
}

This function demonstrates several important Rust patterns:

  • Option for fields that may or may not be present
  • ok_or and ok_or_else to convert missing values into errors
  • map_err to translate lower-level parsing failures
  • early returns for clear control flow

Loading from a file

Now add a helper that reads a file and parses it.

use std::fs;
use std::path::Path;

pub fn load_config(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
    let contents = fs::read_to_string(path).map_err(ConfigError::Io)?;
    parse_config(&contents)
}

This is a clean example of error conversion. fs::read_to_string returns std::io::Error, which we wrap in ConfigError::Io. The parser then handles domain-specific validation.

Best practice: keep error conversion at boundaries

A useful rule is to convert external errors into your own type as soon as they enter your library boundary. That keeps the rest of your code focused on your domain instead of leaking implementation details everywhere.

Using the library from an application

Here is how a binary crate might call your library:

use config_errors::load_config;

fn main() {
    match load_config("app.conf") {
        Ok(config) => {
            println!("Loaded config: {:?}", config);
        }
        Err(err) => {
            eprintln!("Failed to load configuration: {}", err);
            std::process::exit(1);
        }
    }
}

This pattern is common in Rust applications: internal code returns structured errors, while the top-level main function decides how to present them.

Matching on specific errors

One advantage of a custom enum is the ability to handle cases differently.

use config_errors::ConfigError;

fn handle_error(err: ConfigError) {
    match err {
        ConfigError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
            eprintln!("Configuration file not found.");
        }
        ConfigError::MissingField(field) => {
            eprintln!("Missing required setting: {}", field);
        }
        other => {
            eprintln!("Error: {}", other);
        }
    }
}

This is much harder to do reliably with plain strings. Matching on variants gives you precise control over recovery and messaging.

Error design comparison

ApproachStrengthsWeaknesses
Result<T, String>Simple, fast to writeHard to match, no source chaining, weak for large codebases
Custom enumTyped, expressive, easy to matchRequires more upfront design
Third-party error wrapper | e.g. anyhowGreat for application-level contextLess ideal for public libraries that need stable variants

For a library, a custom enum is usually the best starting point. For an application, you may later layer a convenience wrapper on top.

Adding tests for failure cases

Error handling code should be tested as carefully as success paths. Add unit tests that verify both parsing and validation.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_valid_config() {
        let input = "name=demo\nport=8080\n";
        let config = parse_config(input).unwrap();

        assert_eq!(
            config,
            Config {
                name: "demo".to_string(),
                port: 8080
            }
        );
    }

    #[test]
    fn rejects_missing_port() {
        let input = "name=demo\n";
        let err = parse_config(input).unwrap_err();

        match err {
            ConfigError::MissingField(field) => assert_eq!(field, "port"),
            other => panic!("unexpected error: {:?}", other),
        }
    }

    #[test]
    fn rejects_invalid_port() {
        let input = "name=demo\nport=abc\n";
        let err = parse_config(input).unwrap_err();

        match err {
            ConfigError::InvalidValue { field, value } => {
                assert_eq!(field, "port");
                assert_eq!(value, "abc");
            }
            other => panic!("unexpected error: {:?}", other),
        }
    }
}

These tests ensure your error variants remain stable and meaningful as the parser evolves.

Practical best practices

When designing error handling in Rust, keep these guidelines in mind:

  1. Use enums for public library errors. They make your API explicit and testable.
  2. Preserve source errors when possible. Wrapping io::Error or parser errors helps debugging.
  3. Separate user-facing messages from internal structure.Display is for presentation; the enum is for logic.
  4. Validate early and return precise variants. Avoid collapsing everything into one generic failure.
  5. Keep top-level error handling simple. Let main or the outer layer decide how to print or log failures.

When to evolve this pattern

This tutorial used only the standard library, which is ideal for learning and for small utilities. In larger projects, you may want to add:

  • richer context with stack-like error chains
  • automatic conversions between error types
  • integration with logging or tracing
  • serialization of error details for APIs

The core idea remains the same: model failures explicitly and keep them structured.

Conclusion

A custom error type is one of the most useful early patterns to learn in Rust. It improves clarity, makes APIs easier to use, and gives you a strong foundation for larger applications. By building a small configuration parser with typed errors, you practiced the same techniques used in production Rust code: enum-based error modeling, source preservation, conversion at boundaries, and targeted validation.

Once this pattern feels natural, you can apply it to file loaders, network clients, parsers, and command-line tools with much more confidence.

Learn more with useful resources