Crabrix blog

Rust errors that help: design a Result your caller can use

Build a small quantity parser with useful errors, propagate failures without losing their meaning, and turn failure cases into tests and readable output.

Begin with the person receiving the error

A programme that accepts user input will meet values it cannot use. Someone submits an empty quantity, types a word where a number belongs, or requests more than the allowed amount. Those failures are part of the normal interface. A useful error tells the caller what happened and leaves room for an appropriate response: ask for another value, skip a record, show a message, or stop the operation with a clear status.

In this article we will parse the quantity for a small workshop order. The quantity must be an integer from one to one hundred. Our goal is more precise than removing every unwrap: we want each expected failure to have a name, every successful value to satisfy the contract, and the programme boundary to explain a failure without exposing irrelevant internals. You can build the example in Crabrix using only the standard library and then adapt the same design to a file or command-line tool.

A crab at an input workshop sends a valid order along a mint route and a rejected order to a helpful repair station.
Expected input failures need a useful route back to the caller, not an unexplained crash.

Result makes two outcomes explicit

A Result<T, E> contains either Ok(T) or Err(E). For our parser, the successful value is a u32, and the error type describes why no accepted quantity was produced. The type forces a caller to account for the two possibilities when it wants to obtain the success value. Returning an error does not by itself print anything, close a window, or retry a request. Those are decisions made by the caller.

Keep the success contract as important as the failure contract. If the function returns Ok(7), the caller should know that seven satisfied the parser's documented range. It should not need to repeat the same range check just to trust the result. Think of the function as a boundary between raw input and a value the next stage can use. That boundary becomes easier to maintain when success and failure both have a clear meaning.

Sources: Result in the standard library

Name the failures your caller cares about

Our workshop distinguishes missing input, input that cannot be parsed as u32, and a parsed quantity outside the allowed range. Those categories correspond to different user guidance. Empty input suggests entering a value. An invalid integer suggests checking the format. An out-of-range value suggests choosing a quantity between the documented limits. A string such as something went wrong would discard information we already have at the point of failure.

Represent those cases with an enum. The enum is a compact vocabulary shared by the parser, tests and interface. Start with the cases that have a real use. A huge error hierarchy invented before you have callers can make a small programme harder to follow. Conversely, if the caller needs to distinguish two failures, collapsing them into one generic category makes the next layer guess. The right level of detail comes from the responses the application actually supports.

#[derive(Debug, PartialEq)]
enum QuantityError {
    Empty,
    InvalidInteger,
    OutOfRange { value: u32 },
}

Sources: Enums in the Rust Book

Write the parser as a sequence of decisions

First trim surrounding whitespace, because our interface deliberately accepts a quantity with spaces around it. Next reject an empty string. Parse the remaining text as u32, translating a parse failure into our public error category. Finally enforce the range and return the quantity. The order matters: it tells us which error an input should receive. Document that extremely large decimal input may fail parsing before it reaches the range check, because it does not fit into u32.

This sequence is easy to trace by hand. For a blank string, stop at Empty. For twelve written as letters, stop at InvalidInteger. For zero, parsing succeeds but the range check fails. For seven, every stage succeeds. Do that trace before adding more code. A parser that you can explain through four small inputs gives you a reliable foundation for a user-facing message and a test suite.

#[derive(Debug, PartialEq)]
enum QuantityError { Empty, InvalidInteger, OutOfRange { value: u32 } }

fn parse_quantity(input: &str) -> Result<u32, QuantityError> {
    let text = input.trim();
    if text.is_empty() { return Err(QuantityError::Empty); }
    let value = text.parse::<u32>()
        .map_err(|_| QuantityError::InvalidInteger)?;
    if !(1..=100).contains(&value) {
        return Err(QuantityError::OutOfRange { value });
    }
    Ok(value)
}

Sources: Parsing strings

The question mark propagates a failure

In the parser, ? unwraps the successful parsed value or returns the error from the current function. It is an early return on the error path, not a retry and not a panic. Our map_err changes the parser's library error into the application category before propagation. That conversion is a conscious choice: this small interface needs to identify an invalid integer, while detailed parse internals would not change the user's next action.

In a larger system, preserving the underlying error may be important for diagnosis. Decide that from the needs of the next layer rather than automatically replacing every error with a sentence. A concise ? can make a sequence of fallible operations readable, but it cannot decide what context the caller will need. Read a chain of question marks as a series of exit points and ask what information travels along each exit.

Result branches into Ok(value), which continues, and Err(error), which returns to the caller through the question mark operator.
The diagram shows propagation from the current function. The caller chooses whether to show, retry, skip or stop.

Sources: Propagating errors in the Rust Book

Handle the result at the interface boundary

The part of the programme talking to a person should turn an error into a response. Use match to display a clear message for each quantity error. The successful path can create the workshop order. The parser remains reusable because it does not decide whether output belongs in a terminal, a sheet, a form or a test. Separating parsing from presentation also keeps its tests small and predictable.

Write messages that help someone correct the input. Enter a quantity gives a next step. Use a whole number identifies the required format. Choose a quantity from one to one hundred communicates the accepted range. Do not copy an entire stack of internal details into the normal interface just because you have them. Keep useful diagnostic information available for development, while giving the user a response suited to the task they are trying to complete.

fn show_order(input: &str) {
    match parse_quantity(input) {
        Ok(value) => println!("Order accepted: {value} items"),
        Err(QuantityError::Empty) => println!("Enter a quantity."),
        Err(QuantityError::InvalidInteger) => println!("Use a whole number."),
        Err(QuantityError::OutOfRange { .. }) => {
            println!("Choose a quantity from 1 to 100.");
        }
    }
}

Add context without losing the original cause

Imagine importing twenty workshop orders from a file. InvalidInteger now needs context: which row contained the invalid quantity? Add that information at the layer that knows about rows, rather than teaching the basic quantity parser about files. The low-level function can still report the quantity error, while the import layer attaches a row number. This preserves reuse and makes the report actionable for the person fixing the file.

For systems errors, an error chain can retain an underlying cause while adding the operation that failed. The standard Error trait supports reporting a source error, and Display supplies a readable representation. Our small enum does not need that machinery to demonstrate the idea, but larger application boundaries may benefit from it. The important design question is whether a caller can understand both the failed operation and the reason, without reverse-engineering a generic message.

Sources: The standard Error trait

Choose panic for a different kind of failure

Expected invalid input should normally travel through a result the caller can handle. A panic has a different role: it can signal a violated assumption or an unrecoverable situation in the selected programme design. unwrap and expect panic when their expected success is absent. They can be useful in tightly controlled examples or tests, but using them on arbitrary user input turns an ordinary correction into abrupt failure.

Ask what the interface promises before choosing a response. If the workshop app accepts text entered by a person, malformed text is foreseeable. If a test constructs a value that must be valid for the test itself to make sense, a failed expectation can clearly identify a broken setup. Write the invariant beside that expectation. Avoid calling every unexpected value a programmer error when it actually arrived through an external boundary you have not validated.

Sources: When to panic

Test the contract at its boundaries

Turn the earlier hand traces into tests. Check empty input, invalid text, zero, the lowest accepted value, the highest accepted value and the first value above it. Add surrounding whitespace because accepting it was a deliberate part of the parser contract. Check a value too large for u32 so the parse-versus-range distinction remains documented. These cases protect decisions a user can observe, rather than simply repeating the function's internal lines.

Test error categories as values. Comparing an enum is usually less fragile than comparing a complete English message, whose wording you may improve later. Then add a small interface check for the messages you actually show. Keep both questions visible: did the parser classify the failure correctly, and did the interface help the user? A successful test run is persuasive when you can explain the behaviours it covers and the behaviours still outside its scope.

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn quantity_contract() {
        assert_eq!(parse_quantity(" "), Err(QuantityError::Empty));
        assert_eq!(parse_quantity("many"), Err(QuantityError::InvalidInteger));
        assert_eq!(parse_quantity("0"), Err(QuantityError::OutOfRange { value: 0 }));
        assert_eq!(parse_quantity(" 1 "), Ok(1));
        assert_eq!(parse_quantity("100"), Ok(100));
        assert_eq!(parse_quantity("101"), Err(QuantityError::OutOfRange { value: 101 }));
        assert_eq!(parse_quantity("999999999999999999999"), Err(QuantityError::InvalidInteger));
    }
}

Avoid accidental disclosure in diagnostics

Error context is useful, but more detail is not automatically better. A failed file operation might involve a private path; a failed request might include a token; a rejected record might contain personal information. Decide which context belongs in ordinary output, which belongs in restricted diagnostic output, and which should not be retained at all. Design that decision alongside the error type instead of adding indiscriminate logging after a failure appears.

For our workshop example, a row number and a category usually help without reproducing the entire order. During local development, you can examine a controlled fixture to investigate a bug. For a deployed product, avoid treating arbitrary user content as a harmless diagnostic string. This is an application design concern, not a capability Result supplies automatically. The type makes failure explicit; you still choose a responsible and useful way to present it.

Practise a failure story from input to response

Copy the parser into a small project, add the display function, and call it with each fixture. Predict the result before running it. Then change the accepted range and update the contract tests. Add a rule that the quantity must be even, with its own named error. Decide where that check belongs in the sequence and which error an out-of-range odd number should receive. Your answer should follow a documented rule rather than accidental branch order.

Finish by explaining one failure end to end: the original input, the validation step that rejected it, the error value returned, the layer that handled it, and the message the person received. Then explain one successful path with the same precision. When both stories are clear, you have designed more than a parser that compiles. You have a reusable boundary whose outcomes the next part of the programme can trust, test and communicate.

Explore the free Crabrix lessons →