← Table of contents

2.4 A Name for the Absent

A messenger who returns empty-handed is useful exactly to the extent that they can explain what they failed to find. “Didn’t find it” ends the conversation. “Didn’t find this specific thing” begins the next one. Most tools prefer the first. This is usually called brevity, though the accurate word is somewhat different.

What You Need

get returns Option<&Task>. In chapter 2.1 this was the right choice: a task either exists or it doesn’t, there are no reasons for its absence - only the fact. Since then tq has changed. Errors now have types; functions share a common language: Result.

Option has two limitations in the current context.

The first: ? does not work with it. main returns Result<(), TqError>, and trying to apply ? to an Option<&Task> produces a compiler error: Option and Result are different types.

The second: absence is anonymous. None says “no.” It does not say “no task with id 42.” When an error travels upward, the caller loses context: which task specifically was not found?

TqError can carry information. Add a NotFound(u64) variant - it will hold the task id. get should return exactly that.

The Build

First, add the new error variant:

#[derive(Debug)]
enum TqError {
    EmptyTitle,
    NotFound(u64),
}

When printed for debugging, this looks like NotFound(42) - specific and unambiguous.

Now get. The old version returned Option<&Task> through find. The new version uses .ok_or(...) - an Option method that turns None into Err(e) and Some(v) into Ok(v):

fn get(tasks: &[Task], id: u64) -> Result<&Task, TqError> {
    tasks.iter().find(|task| task.id == id).ok_or(TqError::NotFound(id))
}

find searches; .ok_or(...) wraps the absence into a named error carrying the id.

In main, both calls to get use match. The patterns change from Some/None to Ok/Err:

match get(&tasks, 1) {
    Ok(task) => println!("found: #{}: {}", task.id, task.title),
    Err(e) => println!("error: {:?}", e),
}

match get(&tasks, 99) {
    Ok(task) => println!("#{}: {}", task.id, task.title),
    Err(TqError::NotFound(id)) => println!("task {} not found", id),
    Err(e) => println!("error: {:?}", e),
}

The second match destructures NotFound(id) - extracting the id from the error and using it directly.

The Err(e) arm in this match is currently unreachable: get only produces NotFound, it has no other errors. But the compiler sees TqError as a whole and requires all variants to be covered. This catch-all is an accepted Rust pattern: when TqError grows and new variants appear, this arm will catch them without any change to the code.

The Result

cargo run now prints:

task 99 not found

Not “not found” - “task 99 not found”. The messenger named what they failed to find.

? now works with get. A function that looks up a task and wants to pass the error upward can write get(&tasks, id)? - and NotFound(id) will travel to the caller without an extra match.

One new test for this change - it checks that the id is preserved in the error:

#[test]
fn get_carries_missing_id_in_error() {
    let tasks: Vec<Task> = Vec::new();
    assert!(matches!(get(&tasks, 99), Err(TqError::NotFound(99))));
}

matches!(expr, pattern) returns true if the expression matches the pattern - including nested values. The full test suite is in tq/.

make ci passes. get no longer stays silent about what it was looking for.

The complete tq code for this chapter is in 2-honest/04-a-name-for-the-absent/.


Lore: Option and Result - Two Ways to Say No

Option says: a value is either present or absent. No details - just the fact. Use it when absence is a normal outcome that requires no explanation. Vec::first() returns Option<&T>: an empty vector is not an error, just a state.

Result says: an operation either succeeded or failed - and if it failed, here is why. Use it when the caller needs to know the reason.

A practical rule: if None is a normal working outcome, use Option. If None means “something went wrong,” use Result.