
Getting Started with Rust: Building a Structured Error Handling Library
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_errorsYour 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::Iomight be recoverable if the file path is wrongConfigError::MissingFieldis a user input problemConfigError::Parseindicates 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=8080We 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:
Optionfor fields that may or may not be presentok_orandok_or_elseto convert missing values into errorsmap_errto 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
| Approach | Strengths | Weaknesses |
|---|---|---|
Result<T, String> | Simple, fast to write | Hard to match, no source chaining, weak for large codebases |
| Custom enum | Typed, expressive, easy to match | Requires more upfront design |
Third-party error wrapper | e.g. anyhow | Great for application-level context | Less 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:
- Use enums for public library errors. They make your API explicit and testable.
- Preserve source errors when possible. Wrapping
io::Erroror parser errors helps debugging. - Separate user-facing messages from internal structure.
Displayis for presentation; the enum is for logic. - Validate early and return precise variants. Avoid collapsing everything into one generic failure.
- Keep top-level error handling simple. Let
mainor 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.
