Custom Errors: From Display to thiserror

Look at this! Day 26, and today we’re talking about one of my favorite things to not ignore: error messages. Because when things go sideways (and they will), your future self and your users will appreciate error handling that’s clear, structured, and helpful.

If you’re coming from C#, you’re used to building custom exception classes when the built-in ones just don’t cut it:

public class UserNotFoundException : Exception
{
    public UserNotFoundException(string username)
        : base($"User '{username}' was not found.")
    {
    }
}

In Rust, we do something similar, but instead of throwing exceptions, we define custom error types that work with the Result<T, E> system. And with a little help from the fantastic thiserror crate, it’s easier than you might expect.

Custom Error Types: The Basics

Here’s how you might define a basic error type in Rust:

use std::fmt;

#[derive(Debug)]
pub enum MyError {
    NotFound(String),
    InvalidInput(String),
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MyError::NotFound(item) => write!(f, "{} not found", item),
            MyError::InvalidInput(reason) => write!(f, "Invalid input: {}", reason),
        }
    }
}

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

This gives you structured, meaningful errors that play nicely with Result<T, E>. But writing Display and Error by hand every time? A bit tedious.

Enter thiserror: The Easy Button

The thiserror crate automates most of the boilerplate, keeping your error definitions clean and readable:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("{0} not found")]
    NotFound(String),

    #[error("Invalid input: {0}")]
    InvalidInput(String),
}

That’s it. No manual impl Display. No manual impl Error. thiserror handles it all.

Using Custom Errors in Practice

Here’s how you might use that custom error in a function:

fn find_user(username: &str) -> Result<String, MyError> {
    if username == "woodydev" {
        Ok(String::from("User found!"))
    } else {
        Err(MyError::NotFound(username.to_string()))
    }
}

fn main() {
    match find_user("unknown") {
        Ok(message) => println!("{}", message),
        Err(e) => println!("Error: {}", e),
    }
}

Compared to C# Exception Hierarchies

In C#, custom exceptions are about creating class hierarchies and relying on inheritance:

try
{
    throw new UserNotFoundException("woodydev");
}
catch (UserNotFoundException ex)
{
    Console.WriteLine(ex.Message);
}

The downside? Exceptions are runtime beasts, and they need to be caught explicitly and forgetting to catch the right one can lead to unpredictable behavior.

In Rust, because errors are values, you can:

  • Compose them.
  • Wrap them.
  • Chain them.
  • Handle them at compile time.

It feels more like working with actual data instead of handling side effects.

Chaining Errors with #[from]

Another slick feature of thiserror is easy error conversion with the #[from] attribute:

use std::io;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("IO error: {0}")]
    Io(#[from] io::Error),

    #[error("Invalid data provided")]
    InvalidData,
}

Now, anytime a function returns an io::Error, Rust can automatically convert it into your MyError::Io variant using the ? operator. No glue code needed.

Wrapping It Up

Rust’s approach to custom errors is all about making your error handling explicit, structured, and, dare I say, pleasant. With enums, pattern matching, and handy tools like thiserror, you get the flexibility of detailed error types without the pain of hand-rolling exception hierarchies.

Next up, we’re looking at logging in Rust because when things go wrong, it’s nice to leave a paper trail. See you there!

Share:

Leave a reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.