Skip to content

← Errors Are Values step 14 of 24

Medium Primitives

Implementing std::error::Error

Retire Result<_, String>. Build a real error type and implement std::error::Error for it by hand.

pub enum ValidationError {
    Empty,
    TooLong { len: usize, max: usize },
    BadChar { ch: char, index: usize },
}

pub fn validate(name: String, max: usize) -> Result<String, ValidationError>

validate returns the name unchanged if it passes, and otherwise the first rule it breaks — checked in this order:

  1. empty → Empty
  2. more than max characters (not bytes) → TooLong { len, max }
  3. any character that is not alphanumeric and not _BadChar { ch, index } with the zero-based character index of the first offender

The Display strings are the contract

The tests assert e.to_string() exactly:

variant Display
Empty name must not be empty
TooLong { len: 11, max: 5 } name is 11 characters, maximum is 5
BadChar { ch: '-', index: 2 } invalid character '-' at index 2

They follow the std convention, which is worth adopting permanently: lowercase, no trailing punctuation, and concise — “unexpected end of file”, not “Unexpected end of file!”. The reason is composition: your message will be printed inside somebody else’s sentence, after a :, in a log line, or in a chain of causes. A capital letter and a full stop in the middle of that reads like a typo.

The tests also assert format!("{e:?}"), so the variant and field names in the declaration above must be exactly as written.

Read the starter’s E0277

error[E0277]: `ValidationError` doesn't implement `std::fmt::Display`
   |
   | impl std::error::Error for ValidationError {}
   |                            ^^^^^^^^^^^^^^^ `ValidationError` cannot be
   |                                            formatted with the default
   |                                            formatter
   = note: required by a bound in `std::error::Error`

That is the whole trait, right there in the error message. Here is its actual declaration:

pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { None }
    // ...plus some deprecated and unstable methods you should ignore
}

Two supertraits, no required methods. So a minimal implementation is exactly three things:

  1. #[derive(Debug)] — for programmers and for unwrap‘s panic message;
  2. a hand-written impl Display — for humans;
  3. impl std::error::Error for MyError {} — an empty block that says “this is an error”, enabling Box<dyn Error>, ? conversions and source() chains.

There is nothing else. If that feels anticlimactic, good — it is why thiserror can generate the whole thing from an attribute, and why you should understand it before you use thiserror.

Writing the Display impl

use std::fmt;

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ValidationError::Empty => write!(f, "name must not be empty"),
            // ...
        }
    }
}

write!(f, …) returns fmt::Result, so the match arms are the return value — no semicolons, no explicit Ok(()).

Two errors you may meet on the way:

  • E0407, “method description is not a member of trait Error“. Old blog posts and Stack Overflow answers implement fn description(&self) -> &str. It was deprecated in 1.42 and removed from the trait’s required surface. Do not write it.
  • E0599 if you call .to_string() on your error without a Display impl — ToString is blanket-implemented for every T: Display and nothing else.

The rules the API guidelines add

  • Never use () as an error type. You already met clippy::result_unit_err.
  • Error types should be Send + Sync + 'static. This enum is all three for free (it holds only usize and char), but the moment you put a non-Send type in a variant you cut your callers off from thread::spawn and from most async runtimes.
  • Display describes this error and nothing else. It never renders the cause chain. That is what source() is for, and a caller walks it. If you bake the cause into your own Display, every chain-rendering caller prints it twice. This becomes very concrete two items from now.

Notes

  • clippy::error_impl_error (restriction, off here) objects to naming a type ErrorValidationError is fine, Error in your own module is confusing.
  • chars().count() is O(n) and gives characters; len() is O(1) and gives bytes. "héllo" is 5 characters, 6 bytes. The tests check that you picked the right one.
  • char::is_alphanumeric is Unicode-aware, so "日本語" passes. That is usually what you want for a human-facing name field.

Remember the grade is compile + tests + clippy -D warnings.