Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait

Published on HivePostify by @scipio · Sat Sep 05 2026

Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait

What will I learn - You will learn how to design a custom error type as an enum of failure cases; - how to implement Display for a human-readable message and derive Debug for developers; - what the std::error::Error trait is and how source builds an error chain; - how From impls let the ? operator convert underlying errors into yours; - when to return a concrete error type versus a flexible Box .

Requirements - A working modern computer running macOS, Windows or Ubuntu; - An installed Rust toolchain (via rustup, from rustup.rs); - The previous forty-seven episodes, especially error handling (episode 6), Display and From (episode 21); - The ambition to learn systems programming from the ground up.

Difficulty - Intermediate

Curriculum (of the Learn Rust Series): - [Learn Rust Series (#1) - Introduction to Rust](https://hive.blog/hive-196387/@scipio/learn-rust-series-1-introduction-to-rust) - [Learn Rust Series (#2) - Variables, Types, Functions](https://hive.blog/hive-196387/@scipio/learn-rust-series-2-variables-types-functions) - [Learn Rust Series (#3) - Ownership & Borrowing](https://hive.blog/hive-196387/@scipio/learn-rust-series-3-ownership-borrowing) - [Learn Rust Series (#4) - Control Flow & Pattern Matching](https://hive.blog/hive-196387/@scipio/learn-rust-series-4-control-flow-pattern-matching) - [Learn Rust Series (#5) - Structs & Enums](https://hive.blog/hive-196387/@scipio/learn-rust-series-5-structs-enums) - [Learn Rust Series (#6) - Error Handling](https://hive.blog/hive-196387/@scipio/learn-rust-series-6-error-handling) - [Learn Rust Series (#7) - Collections](https://hive.blog/hive-196387/@scipio/learn-rust-series-7-collections) - [Learn Rust Series (#8) - Traits & Generics](https://hive.blog/hive-196387/@scipio/learn-rust-series-8-traits-generics) - [Learn Rust Series (#9) - Modules & Crates](https://hive.blog/hive-196387/@scipio/learn-rust-series-9-modules-crates) - [Learn Rust Series (#10) - Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-10-lifetimes) - [Learn Rust Series (#11) - Closures & the Iterator Trait](https://hive.blog/hive-196387/@scipio/learn-rust-series-11-closures-the-iterator-trait) - [Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-12-smart-pointers-box-rc-refcell) - [Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex](https://hive.blog/hive-196387/@scipio/learn-rust-series-13-concurrency-threads-channels-arc-mutex) - [Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App](https://hive.blog/hive-196387/@scipio/learn-rust-series-14-mini-project-a-command-line-to-do-app) - [Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-15-trait-objects-dynamic-dispatch) - [Learn Rust Series (#16) - Static vs Dynamic Dispatch](https://hive.blog/hive-196387/@scipio/learn-rust-series-16-static-vs-dynamic-dispatch) - [Learn Rust Series (#17) - Associated Types vs Generic Parameters](https://hive.blog/hive-196387/@scipio/learn-rust-series-17-associated-types-vs-generic-parameters) - [Learn Rust Series (#18) - Operator Overloading with std::ops](https://hive.blog/hive-196387/@scipio/learn-rust-series-18-operator-overloading-with-stdops) - [Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion](https://hive.blog/hive-196387/@scipio/learn-rust-series-19-deref-derefmut-deref-coercion) - [Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)](https://hive.blog/hive-196387/@scipio/learn-rust-series-20-drop-deterministic-destruction-raii) - [Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions](https://hive.blog/hive-196387/@scipio/learn-rust-series-21-from-into-tryfrom-idiomatic-conversions) - [Learn Rust Series (#22) - Deriving Common Traits](https://hive.blog/hive-196387/@scipio/learn-rust-series-22-deriving-common-traits) - [Learn Rust Series (#23) - The Orphan Rule & Trait Coherence](https://hive.blog/hive-196387/@scipio/learn-rust-series-23-the-orphan-rule-trait-coherence) - [Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern](https://hive.blog/hive-196387/@scipio/learn-rust-series-24-blanket-implementations-the-newtype-pattern) - [Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy](https://hive.blog/hive-196387/@scipio/learn-rust-series-25-marker-traits-sized-send-sync-copy) - [Learn Rust Series (#26) - Const Generics: Types That Depend on Values](https://hive.blog/hive-196387/@scipio/learn-rust-series-26-const-generics-types-that-depend-on-values) - [Learn Rust Series (#27) - Generic Associated Types & Lending Iterators](https://hive.blog/hive-196387/@scipio/learn-rust-series-27-generic-associated-types-lending-iterators) - [Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-28-sealed-traits-designing-stable-apis) - [Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System](https://hive.blog/hive-196387/@scipio/learn-rust-series-29-typestate-programming-state-machines-in-the-type-system) - [Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library](https://hive.blog/hive-196387/@scipio/learn-rust-series-30-mini-project-a-generic-units-of-measure-library) - [Learn Rust Series (#31) - Move Semantics Deep Dive](https://hive.blog/hive-196387/@scipio/learn-rust-series-31-move-semantics-deep-dive) - [Learn Rust Series (#32) - Interior Mutability: Cell & RefCell](https://hive.blog/hive-196387/@scipio/learn-rust-series-32-interior-mutability-cell-refcell) - [Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership](https://hive.blog/hive-196387/@scipio/learn-rust-series-33-rc-internals-reference-counting-shared-ownership) - [Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost](https://hive.blog/hive-196387/@scipio/learn-rust-series-34-arc-thread-safe-reference-counting-its-cost) - [Learn Rust Series (#35) - Weak References & Breaking Reference Cycles](https://hive.blog/hive-196387/@scipio/learn-rust-series-35-weak-references-breaking-reference-cycles) - [Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs](https://hive.blog/hive-196387/@scipio/learn-rust-series-36-cow-clone-on-write-for-borrow-or-own-apis) - [Learn Rust Series (#37) - Pin & Self-Referential Structs](https://hive.blog/hive-196387/@scipio/learn-rust-series-37-pin-self-referential-structs) - [Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes](https://hive.blog/hive-196387/@scipio/learn-rust-series-38-phantomdata-zero-sized-types-marker-lifetimes) - [Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters](https://hive.blog/hive-196387/@scipio/learn-rust-series-39-variance-covariance-contravariance-why-it-matters) - [Learn Rust Series (#40) - Arena & Bump Allocation Patterns](https://hive.blog/hive-196387/@scipio/learn-rust-series-40-arena-bump-allocation-patterns) - [Learn Rust Series (#41) - Building Your Own Smart Pointer](https://hive.blog/hive-196387/@scipio/learn-rust-series-41-building-your-own-smart-pointer) - [Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety](https://hive.blog/hive-196387/@scipio/learn-rust-series-42-drop-order-the-drop-check-leak-safety) - [Learn Rust Series (#43) - std::mem: swap, replace, take & forget](https://hive.blog/hive-196387/@scipio/learn-rust-series-43-stdmem-swap-replace-take-forget) - [Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision](https://hive.blog/hive-196387/@scipio/learn-rust-series-44-higher-ranked-trait-bounds-lifetime-elision) - [Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe](https://hive.blog/hive-196387/@scipio/learn-rust-series-45-mini-project-a-doubly-linked-list-safe-then-unsafe) - [Learn Rust Series (#46) - Result Combinators: map, maperr, andthen, okor](https://hive.blog/hive-196387/@scipio/learn-rust-series-46-result-combinators-map-maperr-andthen-okor) - [Learn Rust Series (#47) - Option Combinators & Null-Free Programming](https://hive.blog/hive-196387/@scipio/learn-rust-series-47-option-combinators-null-free-programming) - [Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait](https://hive.blog/hive-196387/@scipio/learn-rust-series-48-custom-error-types-the-stderrorerror-trait) (this post)

Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait

The last two episodes were about the ergonomics of handling errors -- the combinators that let Result and Option flow through a function without a match on every line. Today we flip the telescope around and look at the errors themselves. Because so far, whenever something has gone wrong in our tutorials, our functions have shrugged and returned a String. That is fine for teaching, and it is fine for a fifty-line script. But a String error is a dead end: you cannot match on it to react to which thing failed, you cannot attach structured data to it (the offending key, the byte offset, the underlying OS error), and you throw away the original cause the moment you reformat it into prose.

Real programs deserve better, and Rust gives you a proper vocabulary for it: a custom error type that describes exactly what can go wrong, prints a clear message, chains to its underlying cause, and plugs straight into the ? operator. The standard library standardises all of this through one small trait, std::error::Error. Implementing it by hand once, as we do here, teaches you exactly what the popular error crates generate for you behind the scenes -- so when we reach those crates in the next couple of episodes, there will be no magic left ;-)

Having said that, before we look forward we owe episode 47 its homework. I left three exercises on the Option combinators, and skipping the solutions would be cheating you.

Solutions to Episode 47 Exercises

Episode 47 was Option combinators and null-free programming. Here is full, runnable code for each of the three exercises -- complete programs, not fragments, so you can paste and run them.

Exercise 1 asked for the second word of a string as an Option, mapped to uppercase, tested on a normal string, a single-word string, and the empty string: rust fn secondupper(s: &str) -> Option { s.splitwhitespace().nth(1).map(|w| w.touppercase()) }

fn main() { println!("{:?}", secondupper("hello there world")); // Some("THERE") println!("{:?}", secondupper("lonely")); // None println!("{:?}", secondupper("")); // None }

The key insight is that nth(1) already returns an Option -- it is None when there is no second word -- so map simply transforms the value if it is there and leaves None untouched otherwise. No length check, no bounds handling, no branching: the absence is carried by the type.

Exercise 2 wanted HashMap::get, filter, and okor chained to look up a config value and validate it, producing a Result whose error explains what went wrong: rust use std::collections::HashMap;

fn readsetting(cfg: &HashMap , key: &str) -> Result { cfg.get(key) .copied() .filter(|&v| v > 0) .okorelse(|| format!("'{key}' is missing or not positive")) }

fn main() { let cfg: HashMap = [("timeout", 30), ("retries", 0)].intoiter().collect(); println!("{:?}", readsetting(&cfg, "timeout")); // Ok(30) println!("{:?}", readsetting(&cfg, "retries")); // Err("'retries' is missing or not positive") println!("{:?}", readsetting(&cfg, "nope")); // Err("'nope' is missing or not positive") }

Notice how a missing key and a present-but-invalid value collapse to the same None after filter, and okorelse then promotes that single None into an Err. One String error covers both failure modes -- which, as we will see in about three paragraphs, is exactly the limitation we are about to outgrow.

Exercise 3 was the ?-on-Option divide, yielding None if either number is missing or unparseable: rust fn divfirsttwo(s: &str) -> Option { let mut it = s.splitwhitespace(); let a: f64 = it.next()?.parse().ok()?; let b: f64 = it.next()?.parse().ok()?; if b == 0.0 { None } else { Some(a / b) } }

fn main() { println!("{:?}", divfirsttwo("10 2")); // Some(5.0) println!("{:?}", divfirsttwo("10 0")); // None -- guarded divide-by-zero println!("{:?}", divfirsttwo("10")); // None -- no second number println!("{:?}", divfirsttwo("x 2")); // None -- unparseable }

Each next()? bails if a word is missing, each .parse().ok()? converts a parse Result into an Option and then propagates absence. Four independent ways to fail, one flat function, zero match. Right, homework cleared. Now, custom errors.

Why a String error is a dead end

Let me make the problem concrete first, because the motivation is the whole point. Here is the crude way, the way we have been doing it: parse a port number, and if it fails, mash everything into a String. rust fn parseport(s: &str) -> Result { s.parse:: ().maperr(|| format!("bad port: {s}")) }

fn main() { println!("{:?}", parseport("8080")); // Ok(8080) println!("{:?}", parseport("nope")); // Err("bad port: nope") }

This works, but look at what the caller receives: a bag of characters. If the caller wants to do something different for a missing value versus a malformed one versus an out-of-range one, they are stuck doing string matching on the message text -- which is brittle, breaks the instant you reword the message, and is frankly embarrassing. On top of that, we threw the original ParseIntError in the bin the moment we wrote maperr(|| ...). If a support engineer later asks "but why did it not parse?", the honest answer is "we deleted that information". A String is a fine thing to show a human, but a terrible thing to program against.

What we want is a type where each distinct failure is its own thing the caller can match on, that can carry structured data alongside the message, and that remembers what caused it. That type is a plain enum plus two or three trait impls.

An error enum with Display

Start by enumerating the ways an operation can fail, one variant per failure mode. Then implement Display to give each a clear message. Remember the division of labour from episode 22: Debug (which you derive) is the developer-facing dump, Display (which you write) is the human-facing message a user or a log line sees. rust use std::fmt;

#[derive(Debug)] enum ConfigError { NotFound(String), Invalid { key: String, reason: String }, }

impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ConfigError::NotFound(key) => write!(f, "config key not found: {key}"), ConfigError::Invalid { key, reason } => write!(f, "invalid config '{key}': {reason}"), } } }

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

fn main() { let e = ConfigError::NotFound(String::from("timeout")); println!("{e}"); // Display: config key not found: timeout println!("{e:?}"); // Debug: NotFound("timeout") }

Two things earn their keep here. First, the variants carry data -- NotFound owns the offending key, Invalid carries both the key and a reason -- so no information is lost. A caller can match on ConfigError::NotFound() and react precisely, something no String allows. Second, that one-line impl std::error::Error for ConfigError {} is what promotes your type from "some enum" to "a first-class error". The trait requires Display and Debug as supertraits (which is why we implemented one and derived the other), and in exchange your type now slots in anywhere the ecosystem expects an error -- it can be boxed into Box , returned from main, wrapped by other errors, and printed by any tool that speaks the Error trait. That empty impl block looks like it does nothing; what it actually does is grant membership to a very large club.

The source chain

The Error trait has one genuinely useful method with a default implementation you can override: source. It returns the underlying error that caused this one, as an Option . Implementing it links your error to the lower-level error beneath it, building a chain. And -- this is the part that ties it to episode 21 -- a From impl lets the ? operator convert that lower-level error into yours automatically: rust use std::fmt; use std::num::ParseIntError;

#[derive(Debug)] enum AppError { Parse(ParseIntError), OutOfRange(i32) }

impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { AppError::Parse() => write!(f, "could not parse a number"), AppError::OutOfRange(n) => write!(f, "{n} is out of the 0..=100 range"), } } }

impl std::error::Error for AppError { fn source(&self) -> Option { match self { AppError::Parse(e) => Some(e), // chain to the underlying error AppError::OutOfRange() => None, } } }

impl From for AppError { fn from(e: ParseIntError) -> AppError { AppError::Parse(e) } }

fn parsepercent(s: &str) -> Result { let n: i32 = s.parse()?; // ParseIntError converts to AppError via From if (0..=100).contains(&n) { Ok(n) } else { Err(AppError::OutOfRange(n)) } }

fn main() { println!("{:?}", parsepercent("50")); // Ok(50) println!("{:?}", parsepercent("200")); // Err(OutOfRange(200)) println!("{:?}", parsepercent("xx")); // Err(Parse(ParseIntError { .. })) }

This little program is the heart of the episode, so let me trace the machinery. Inside parsepercent, s.parse()? produces a Result . The ? operator sees that the function returns AppError, not ParseIntError, and asks: is there a From for AppError? There is, so ? calls it and wraps the low-level error into AppError::Parse on the way out. This is the exact mechanism that makes ? feel magical across a whole function that touches a dozen different libraries: each foreign error type just needs a From impl into yours, and then ? unifies them all. Meanwhile source preserves the original ParseIntError so nothing is lost -- we now have both a friendly Display message and the precise underlying cause, living together in one value. That is the thing a bare String could never give us.

Walking the cause chain

Because source hands back the underlying error, and that error can have a source of its own, you can walk the entire chain from the top-level failure down to the root cause, printing "caused by" at each level. This is precisely what nice command-line tools do when they print a multi-line error report: rust use std::error::Error; use std::fmt; use std::num::ParseIntError;

#[derive(Debug)] struct RequestError(ParseIntError);

impl fmt::Display for RequestError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "the request could not be processed") } }

impl Error for RequestError { fn source(&self) -> Option { Some(&self.0) } }

fn printchain(mut e: &dyn Error) { println!("error: {e}"); while let Some(src) = e.source() { println!(" caused by: {src}"); e = src; } }

fn main() { let inner = "x".parse:: ().unwraperr(); printchain(&RequestError(inner)); // error: the request could not be processed // caused by: invalid digit found in string }

The while let loop is the whole idea: start at the top error, ask it for its source, print it, then become that source and ask again, until some error returns None and the chain ends. Notice how the top-level Display is deliberately vague and user-friendly ("the request could not be processed") while the root cause is specific and technical ("invalid digit found in string"). That layering is exactly what you want -- a clean headline for the user, the gory detail available underneath for whoever needs to debug it. And you get it essentially for free, just by implementing source honestly on each of your error types.

Tags: #stem#stemsocial#steemstem#rust#programming

View full post on HivePostify →

Join HivePostify — Pakistan's First Web3 Platform →