Your own error type

Lesson 0006 · after 0005 · reading, then a 25-minute drill against a shipped test file · ~40 minutes

Every code block and every compiler message on this page was produced by running it, in a scratch project, today. Nothing is written from memory. The demo domain is a config loader — your project is a task CLI, so nothing here pastes in. Translating is the work.

Where 0005 left you

The drill landed: Display for Task, no unwrap, Command::parse(args)?, eprintln! + process::exit(1), all 17 tests still green. The contract 0003 asked for and never got is now real code.

So look at what is left. Two lines in two different files:

// src/command.rs — the user typed `done` with no id after it
let id = args.get(1).ok_or("id not found")?;

// src/store.rs — the user typed `done 9`, and task 9 does not exist
Err("id not found".to_string())

Two unrelated failures, one identical sentence. A caller cannot tell them apart, and neither can you at 3am. There is a third: Err(String::from("no valid commands")) throws away the word the user actually typed, so your CLI can never say which command it did not recognise.

That is the last thing standing between tasks and a crate you would show an interviewer. Today it becomes one enum and three traits.

Part 1 — What a String error costs

Three costs, and they are not stylistic.

With StringWith an enum
The caller gets prose. To react differently per failure it must match on text — if msg == "id not found" — and that breaks the day you fix a typo. The caller matches on a variant. The compiler checks the arms.
The data is gone. format!("no task with id {id}") flattens the id into text; nothing downstream can use it. The value rides along — NotFound(9) — and the sentence is built at the edge, where the human is.
A misspelled message compiles. A misspelled variant does not.

This is not just taste; it is the published guideline for the language:

"Error types should always implement the std::error::Error trait… Never use () as an error type, even where there is no useful additional information for the error to carry… The error message given by the Display representation of an error type should be lowercase without trailing punctuation, and typically concise."

Rust API Guidelines: C-GOOD-ERR

Note the lowercase rule — that is why "invalid digit found in string", straight from std, has no capital and no full stop. Your messages will match that style, and the shipped tests check it.

Part 2 — The one new trait method: source()

0005 gave you the recipe: #[derive(Debug)], then Display, then the empty impl Error, plus From so a bare ? converts. That is 90% of today's drill and you already have it.

Here is the 10% that is new, and it is the part that makes wrapped errors behave. When one of your variants carries another error — BadPort(ParseIntError) — you now have two sentences for one failure: yours and std's. The Error trait has a slot for the inner one:

pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { ... }
    // …plus deprecated description() / cause(), which you never implement
}

std: std::error::Error — note the supertraits: Debug + Display is a requirement of the trait itself, the same bound idea as 0005's <T: Reading>, applied to a trait instead of a function.

The rule that goes with it is one sentence, and it is easy to get wrong:

"In error types that wrap an underlying error, the underlying error should be either returned by the outer error's Error::source(), or rendered by the outer error's Display implementation, but not both."

std: Error source

So: say your sentence in Display, hand the cause to source(), and never print both. Here is the whole pattern in a config loader — Option in, u16 out, two ways to fail:

#[derive(Debug)]
enum ConfigError {
    Missing(String),
    BadPort(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Missing(key) => write!(f, "missing setting: {}", key),
            // no `{e}` on the next arm - the port text is not worth echoing
            ConfigError::BadPort(_) => write!(f, "port must be a number"),
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ConfigError::BadPort(e) => Some(e),   // the cause lives here instead
            ConfigError::Missing(_) => None,       // nothing underneath this one
        }
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> ConfigError { ConfigError::BadPort(e) }
}

fn port(text: Option<&str>) -> Result<u16, ConfigError> {
    let text = text.ok_or(ConfigError::Missing("port".to_string()))?;
    Ok(text.parse()?)                   // ParseIntError -> ConfigError, via From
}
1. Ok(8080)
2. port must be a number
3. Some("invalid digit found in string")
4. missing setting: port
5. None

Line 2 is your sentence. Line 3 is std's, reached through source() — still available for a log or a --verbose flag, not shoved in the user's face. Lines 4–5: a variant with nothing underneath it returns None, and that is not a gap, it is the answer.

Read Option<&(dyn Error + 'static)> as "maybe a reference to some error, whatever type it is" — the dyn from 0005's Box<dyn Error>, borrowed instead of boxed. Copy the signature; it is not worth memorising.

What the enum buys the caller

Now the payoff that a String can never give you — recovering from one failure and staying fatal on the rest:

fn port_or_default(text: Option<&str>) -> Result<u16, ConfigError> {
    match port(text) {
        Err(ConfigError::Missing(_)) => Ok(8080),   // recover from ONE variant
        other => other,                        // every other failure stays fatal
    }
}
6. Ok(8080)
7. Err("port must be a number")

A missing setting falls back to a default; a typo'd one still fails. Try writing that against Result<u16, String> — you would be comparing prose. This is the shape of every real config loader, retry policy, and HTTP status decision you will write in a backend job.

Part 3 — What the compiler starts doing for you

An enum is a closed set, and the compiler knows all of it. Add a variant to a shipped error type:

enum TaskError {
    …
    NotFound(u32),
    StoreFull,          // new today
}
error[E0004]: non-exhaustive patterns: `&TaskError::StoreFull` not covered
  --> src/error.rs:19:15
   |
19 |         match self {
   |               ^^^^ pattern `&TaskError::StoreFull` not covered
   |
note: `TaskError` defined here
  --> src/error.rs:6:10
   |
 6 | pub enum TaskError {
   |          ^^^^^^^^^
...
14 |     StoreFull,
   |     --------- not covered
   = note: the matched value is of type `&TaskError`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern
      or an explicit pattern as shown

That is the answer to the question you missed in 0005's quiz, delivered by the compiler: adding a failure mode makes the build fail everywhere the new case is unhandled. With a String, adding a failure mode is silent — you find out in production. This is also the argument for not reaching for _ => … in a match on your own error type: the wildcard throws the guarantee away.

Book: 6.2 match (exhaustiveness) · 9.2 ? and From

The three errors you will meet during the migration

Not hypotheticals — I ran your crate with each mistake in place. Recognise them and each costs you ten seconds instead of ten minutes.

1. You wrote the new module but never declared it.

error[E0432]: unresolved import `crate::error`
 --> src/command.rs:1:12
  |
1 | use crate::error::TaskError;
  |            ^^^^^ unresolved import

A file in src/ is not a module until a mod declaration names it. Add pub mod error; to src/lib.rs. (Chapter 7, still true.)

2. You changed the signature but left an old String behind.

error[E0308]: mismatched types
   --> src/store.rs:37:13
    |
 37 |         Err("id not found".to_string())
    |         --- ^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `TaskError`, found `String`
    |         |
    |         arguments to this enum variant are incorrect

This is the migration working as intended: the compiler is listing your remaining String errors one at a time. Follow it until it stops.

3. You used ? on parse() with no From impl.

error[E0271]: type mismatch resolving `<u32 as FromStr>::Err == TaskError`
  --> src/command.rs:31:43
   |
31 |                 Ok(Command::Done { id: id.parse()? })
   |                                           ^^^^^ expected `TaskError`, found `ParseIntError`

0005 predicted exactly this: when the target type comes from context rather than a turbofish, the missing From is reported as E0271 instead of E0277. Same meaning, same fix — write impl From<ParseIntError> for TaskError.

Retrieval — before the drill

From memory. Scrolling up first is the one way to waste these. Two questions are about older material on purpose — interleaving is what makes any of it stick.

Enums

You add a variant to an error enum that is matched in four places. What does the compiler do, and what does the equivalent change to a String error do?

Error handling

Your variant BadId(ParseIntError) wraps another error. Where should std's message appear?

Traits

The trait is declared pub trait Error: Debug + Display. What are those two names doing there, and what happens if your type has neither?

Generics

What does this test actually assert, given that its body is empty? fn assert_usable_as_error<E: Error + Send + Sync + 'static>() {} then assert_usable_as_error::<TaskError>();

Ownership

Why does UnknownCommand(String) hold an owned String rather than a borrowed &str taken from the argument list?

Modules & paths

You create src/error.rs and store.rs says use crate::error::TaskError;. It fails with E0432: unresolved import. Why, and what is the fix?

The drill — 25 minutes, your own crate

Type it, do not paste it. The config loader above is a different program; none of it fits tasks unchanged. Keep the syntax reference open — looking syntax up is free, copying answers is not.

This time the feedback loop is a test file, like 0003. Install it first:

cd ~/learn-rust/tasks
cp ../lessons/0006-errors-spec.rs tests/errors.rs
cargo test            # 7 new tests fail to compile — that is the starting line

Do not edit tests/errors.rs. Do not edit tests/spec.rs either: all 17 must still pass, because the library's behaviour is not changing today — only the type it reports failures with. Target at the end: 24 passing.

Step 1 — src/error.rs

New file, new module. The tests name the variants, so this shape is fixed:

pub enum TaskError {
    NoCommand,                 // no arguments at all
    UnknownCommand(String),    // the word the user actually typed
    MissingTitle,              // `add` with nothing after it
    MissingId,                 // `done` / `remove` with nothing after it
    BadPriority(String),       // the priority word that was not low/medium/high
    BadId(ParseIntError),      // `done abc` — wraps std's parse failure
    NotFound(u32),             // the id that was not in the store
}

Write the four things it needs: the derive, Display, impl Error with source(), and From<ParseIntError>. Two derives are required — Debug because Error demands it, and PartialEq because the tests compare variants with assert_eq!. Miss the second and you get error[E0369]: binary operation == cannot be applied to type TaskError.

The messages are part of the contract — the tests compare them exactly. Lowercase, no trailing punctuation, per C-GOOD-ERR:

Variantto_string() must be
NoCommandno command given
UnknownCommand("fly")unknown command: fly
MissingTitleadd needs a title
MissingIdthis command needs a task id
BadPriority("urgent")unknown priority: urgent
BadId(..)task id must be a number
NotFound(9)no task with id 9

Check: cargo build compiles the library, with pub mod error; added to src/lib.rs.

Stuck for ten minutes on source()?

Imports: use std::error::Error;, use std::fmt;, use std::num::ParseIntError;. The method signature is fn source(&self) -> Option<&(dyn Error + 'static)> — copy it from Part 2, it is not worth deriving. Exactly one variant returns Some(e); a _ => None arm is acceptable here because you are matching to find one case, not to handle every case.

Step 2 — command.rs: Result<Command, TaskError>

Change the signature, then let the compiler walk you through the five ok_or / Err sites. Two of them get better: the _ => arm can now name the word it rejected, and both match id.parse() { Ok(n) => n, Err(_) => return Err(..) } blocks collapse to id.parse()? — that is what the From impl was for.

Check: cargo test --test errors parse_errors_name_the_exact_failure passes, and grep -c "match id.parse" src/command.rs prints 0.

Stuck on the unknown-command arm?

The match arm _ => … discards the value it matched. Bind it instead: other => Err(TaskError::UnknownCommand(other.to_string())). other is a &str (you matched on .as_str()), and the variant holds a String — see the Ownership question above for why.

Step 3 — store.rs: report which id

Both error returns become TaskError::NotFound(id). Nothing else in the file changes.

Check: cargo test --test errors store_reports_which_id_was_missing passes.

Step 4 — main.rs: the edge

run now returns Result<(), TaskError>. Everything else you already wrote in 0005 stays exactly as it is — eprintln!("error: {}", e) keeps working because Display is implemented, and that is the whole point of the trait.

Check — the observable behaviour of your CLI:

$ cargo run --quiet -- fly ; echo $?
error: unknown command: fly
1
$ cargo run --quiet -- done abc ; echo $?
error: task id must be a number
1
$ cargo run --quiet -- done 9 ; echo $?
error: no task with id 9
1
$ cargo run --quiet ; echo $?
error: no command given
1
$ cargo run --quiet -- add "buy milk" 2>/dev/null ; echo $?
added task 1
0
$ cargo test
… 17 passed … 7 passed …

Compare the first three lines with what your CLI said an hour ago — no valid commands, id not found, id not found. Same code paths, same exit codes; the errors now name the thing that went wrong. That is the whole return on one enum.

Then stop

Persistence is the obvious next move and it is deliberately not here: reading and writing a file brings fs, io::Error, a second From impl, and turning a line of text back into a Task. That is lesson 0007, and it is much easier once TaskError exists to convert into.

What you are missing, measured

You asked not to miss anything important, so I mapped this workspace against the book's real table of contents rather than my memory of it: the coverage map. Read it once — it is the shortest honest answer to "where am I?".

The summary: chapters 1–7, 9, and 10.2 are produced, not just read. Three genuine gaps stand between you and a job-ready floor, in the order I intend to teach them:

  1. ch 12 — files and io::Error: your CLI forgets everything on exit (lesson 0007).
  2. ch 8 + 13 — HashMap, map/filter/collect: your weakest measured area, and the most common shape in real Rust code.
  3. ch 11 — writing tests: you have consumed 24 of my tests and written none. A take-home will ask you to produce them.

Lifetimes (ch 10.3) are untouched, and that is fine for now — you have dodged them by owning your data, which is the right call in a CLI. They become unavoidable when you read other people's code.

Take it outside

Once the 24 tests are green, tasks is a small, complete, idiomatic crate — the first thing in this workspace worth showing to strangers. The highest-value thing you can do with it is ask people who write Rust daily whether your error type is idiomatic:

Two things you will likely hear back, and both are worth knowing in advance: real crates often reach for thiserror to derive exactly the Display/From code you just wrote by hand, and applications often use anyhow instead of an enum. Both are correct advice, and both are the wrong place to start — you cannot judge a macro that writes an Error impl until you have written one yourself. Today's version is the one that teaches; reach for the crates on your second real project.

The five sentences worth keeping

  1. An error type is an enum with one variant per way of failing, carrying the data that failed.
  2. Display is the sentence a human reads: lowercase, no trailing punctuation, no repeat of the cause.
  3. source() is where a wrapped error goes — either source() or Display, never both.
  4. From<Cause> for MyError is what makes a bare ? convert; missing, it reports as E0277 or E0271 depending on how the target type was named.
  5. The compiler's exhaustiveness check is the real reason for an enum: adding a failure mode breaks the build, not production.