12.6Custom error types: a first look

Last updated June 13, 2026

Every error in this chapter has been a String. That's the simplest possible error type, and it has a real limit: a String error can only be printed. A caller can't ask "was this a not-found error or a permission error?" and respond differently, because all they have is text. This lesson shows the sturdier approach, an error type built from a chapter 11 enum, and then the pragmatic escape hatch for when defining a type is more effort than the situation warrants. Both lean on chapter 16 (traits) for their full story, so we'll show the shapes and name the forward references honestly.

Why a String error isn't enough

Consider a function that parses an age and can fail two ways: the text isn't a number, or the number is unreasonable. With String errors, the caller is stuck:

fn parse_age(text: &str) -> Result<u32, String> {
    let n: u32 = text.parse().map_err(|_| String::from("not a number"))?;
    if n > 150 {
        return Err(String::from("unreasonably large"));
    }
    Ok(n)
}

If the caller wants to treat "not a number" differently from "too large", say, retry on one but reject outright on the other, they'd have to compare error strings, which is fragile and ugly. The error has structure (it's one of two kinds), but String threw that structure away. This is the lesson 11.1 situation exactly: a value that is one of a fixed set of kinds wants to be an enum.

An error enum

Define the error as an enum, one variant per way the operation can fail, carrying any detail each kind needs:

#[derive(Debug)]
enum AgeError {
    NotANumber,
    TooLarge(u32),
}

fn parse_age(text: &str) -> Result<u32, AgeError> {
    let n: u32 = text.parse().map_err(|_| AgeError::NotANumber)?;
    if n > 150 {
        return Err(AgeError::TooLarge(n));
    }
    Ok(n)
}

fn main() {
    for input in ["42", "abc", "999"] {
        match parse_age(input) {
            Ok(age) => println!("{input}: age {age}"),
            Err(AgeError::NotANumber) => println!("{input}: not a number"),
            Err(AgeError::TooLarge(n)) => println!("{input}: {n} is too large"),
        }
    }
}
42: age 42
abc: not a number
999: 999 is too large

Now the caller can match on the error and respond per kind, with full exhaustiveness checking (lesson 11.1): add a third failure mode later and every caller's match is forced to address it. The TooLarge(n) variant even carries the offending number, so the handler can mention it. This is the chapter 11 toolkit aimed at errors, and it's how serious Rust programs model failure: an enum of the things that can go wrong, inspectable by whoever catches it.

Making it printable: Display

The error enum derives Debug, so {:?} works (lesson 10.7). For a human-facing message, the one a user reads, you implement Display, the {} formatter that lesson 10.7 said you'd write yourself. Here's the shape, with the trait machinery flagged as chapter 16's job:

use std::fmt;

#[derive(Debug)]
enum AgeError {
    NotANumber,
    TooLarge(u32),
}

impl fmt::Display for AgeError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AgeError::NotANumber => write!(f, "input was not a number"),
            AgeError::TooLarge(n) => write!(f, "{n} is too large to be an age"),
        }
    }
}

fn main() {
    let e = AgeError::TooLarge(999);
    println!("{e}");    // uses Display
    println!("{e:?}");  // uses Debug
}
999 is too large to be an age
TooLarge(999)

impl fmt::Display for AgeError { ... } is implementing a trait by hand, which is genuinely chapter 16's subject, so don't worry about the exact syntax yet. The takeaway is the division of labor you met in lesson 10.7, now for errors: Debug ({:?}) is the derivable programmer's view, Display ({}) is the human-facing message you write because only you know how the error should read in a sentence. A well-made error type usually has both.

Key insight

An error type is just a type, and the same chapter 10 and 11 tools build it: an enum when failure comes in distinct kinds, Debug derived for inspection, Display implemented for the human message. There's no separate "exception" language feature to learn (lesson 12.1's ledger: Rust replaces C++'s entire exception chapter with "errors are values of types you define"). What chapter 16 adds is the Error trait that lets all these custom types interoperate.

The escape hatch: Box

Defining a tidy enum is the right move for a library or a long-lived program. But sometimes, especially in a small application or a quick main, you have several different error types flowing through one function, parse errors and file errors and your own errors, and writing a grand enum to unify them is more ceremony than the program deserves. For that, Rust offers a catch-all error type:

use std::error::Error;

fn run() -> Result<(), Box<dyn Error>> {
    let n: i32 = "42".parse()?;
    let m: i32 = "100".parse()?;
    println!("{}", n + m);
    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    run()
}
142

Box<dyn Error> means, roughly, "a pointer to some error, any error." A function returning Result<T, Box<dyn Error>> accepts ? on any error type, because every standard error can become a Box<dyn Error> automatically. It's the pragmatic choice when you want errors to propagate cleanly and you don't need callers to distinguish kinds, just to know something failed and see the message. The cost is exactly that lost distinguishing: you've traded the enum's inspectability for the convenience of not defining one.

The pieces here, dyn, Box, the Error trait, are real topics: Box is a heap pointer (chapter 21), dyn Error is a trait object (chapter 16). We show Box<dyn Error> now, shown-not-explained, because it's the single most useful error type for application mains and you'll want it long before chapter 16. Treat it as a working incantation: "the error type that accepts any error", paired with ? and a main that returns Result (lesson 12.3).

Best practice

For applications and quick programs, reach for Box<dyn Error> and let ? carry everything to a main that returns Result. For libraries and code where callers need to react to specific failures, define an error enum with Debug and Display. The rule of thumb mirrors lesson 12.5: the more your code is reused, the more its errors should be inspectable types rather than an opaque box.

Quiz time

Question #1

Why might a caller prefer an AgeError enum over a String error?

Show solution

An enum lets the caller match on the kind of error and respond differently to each (retry on one, reject on another), with exhaustiveness ensuring new error kinds are handled. A String error can only be printed; its structure is gone, so the caller would have to compare error text, which is fragile. The enum keeps the failure's structure inspectable.

Question #2

What's the difference between deriving Debug and implementing Display for an error type, and why is only one of them derivable?

Show solution

Debug ({:?}) is the programmer-facing structural dump, derivable because "show the variant and its data" has an obvious form. Display ({}) is the human-facing message you implement yourself, because only you know how the error should read in prose, so the compiler can't generate it. Error types usually have both: Debug for logs and Display for users. (This is lesson 10.7's division, applied to errors.)

Question #3

When would you use Box<dyn Error> instead of a custom error enum?

Show solution

When several different error types flow through one function and you don't need callers to tell them apart, just to propagate them and report a message, typically in an application or a quick main. Box<dyn Error> accepts ? on any error type with no enum to define. Use a custom enum instead when callers need to react to specific failure kinds, as in a library.

That's the full error-handling toolkit: panic for bugs, Result for recoverable failures, ? to propagate, and error types built from the same structs and enums you already know. The chapter summary and a capstone quiz are next, and then chapter 13 finally answers a question that's been quietly growing: how do you split a program across multiple files and keep its names from colliding?