Rust / Intermediate / Lesson 7
Result, errors, and recovery
Represent expected failure in return types, propagate context deliberately, and distinguish recoverable domain errors from invariant-breaking panics.
On this page
Lesson goals
- Use Result, matching, and the question-mark operator.
- Keep parsing and domain errors distinguishable.
- Choose a recovery boundary and avoid routine unwrap.
A failure value is part of the contract
Result<T, E> has Ok(T) and Err(E), making success and failure explicit alternatives. A caller must handle or propagate the result before treating it as a successful value. Option expresses absence without explaining a failure cause, so choose Result when the distinction matters. Returning an error does not automatically log, retry, or end the process. Those are policy decisions for a boundary with enough context. Define whether invalid input, missing records, allocation problems, or I/O failures are expected within the operation rather than using one undifferentiated failure string everywhere.
Give errors stable meaningful categories
The first parser converts a complete string to u32 and maps conversion failure into a domain enum. It then checks the accepted maximum independently. The enum lets callers decide different responses without inspecting message text. The shown Debug formatting is checked on the stated verification toolchain; public protocols should not use Debug as a stable serialized format. A public library can implement Display and Error to provide messages and source chains while keeping the error category available for programmatic handling. Avoid storing a borrowed reference to a temporary input in an escaping error.
#[derive(Debug)]
enum CountError { InvalidInteger, TooLarge }
fn count(text: &str) -> Result<u32, CountError> {
let value = text.parse::<u32>().map_err(|_| CountError::InvalidInteger)?;
if value > 100 { return Err(CountError::TooLarge); }
Ok(value)
}
fn main() {
println!("{:?}", count("12"));
println!("{:?}", count("101"));
println!("{:?}", count("x"));
}
Ok(12)
Err(TooLarge)
Err(InvalidInteger)
Twelve succeeds, one hundred one violates the domain, and x cannot parse as an integer.
The question mark propagates, it does not recover
For a Result-returning function, ? extracts the Ok value or returns an appropriate converted error early. It does not swallow the error or retry the operation. The second program uses two validated positive counts and checked addition, preserving a separate overflow category. A function should add context where it knows what operation was being attempted, then allow the chosen boundary to decide the response. Do not repeatedly wrap an error in identical strings at every layer. Keep the original source when it is useful and safe to retain.
fn parse(text: &str) -> Result<u32, &'static str> {
text.parse::<u32>().map_err(|_| "invalid count")
}
fn combine(left: &str, right: &str) -> Result<u32, &'static str> {
let left = parse(left)?;
let right = parse(right)?;
left.checked_add(right).ok_or("sum overflow")
}
fn main() {
println!("{:?}", combine("7", "8"));
println!("{:?}", combine("7", "bad"));
}
Ok(15)
Err("invalid count")
The successful pair totals fifteen. The invalid second input returns an error before addition is attempted.
A panic is not an ordinary validation result
unwrap and expect panic on an unexpected variant. They can document a proven local invariant in a small example, but routine user mistakes should normally become error values. A panic may unwind the stack or abort according to configuration; do not promise that every destructor runs under every panic strategy. catch_unwind is not a general error-handling mechanism and does not catch aborting panics. A service should choose its process and task recovery policy explicitly. Safe Rust still permits denial of service through panic or exhaustion even when memory access remains protected.
Test errors as state transitions
For each fallible operation, state whether failure leaves owned state unchanged, partially updated, or recoverable through a documented action. Parsing into local candidate values before committing often prevents partial updates. Tests should check the precise error category and the state afterward, including malformed text, bounds, and overflow. Retry only when the cause can plausibly change and impose a retry limit. Avoid logging secrets or full untrusted input merely to provide context. Human-readable messages and stable machine categories solve different needs and should be designed together.
Common errors and fixes
Invalid user input terminates the program.
- Why it happens
- A predictable Result error was unwrapped.
- How to fix it
- Match or propagate the error to a recovery boundary.
- Prevent it next time
- Reserve panics for documented broken assumptions.
The question mark is expected to log failures.
- Why it happens
- Propagation was confused with handling.
- How to fix it
- Handle or log once at the chosen boundary.
- Prevent it next time
- Assign recovery ownership to a specific layer.
Callers parse error strings to choose behavior.
- Why it happens
- The API exposes no stable error categories.
- How to fix it
- Return an enum or another structured error type.
- Prevent it next time
- Design machine categories separately from wording.
Check your understanding
1. Add a test for combining the maximum u32 with one. State the result and why it should not depend on release-mode overflow checking.
Show worked solution
Provide the decimal string 4294967295 and "1". Parsing succeeds, but checked_add returns None and combine produces Err("sum overflow"). This is an explicit arithmetic policy, so debug and release builds agree on the error. A test should compare the error value rather than looking for a panic.
2. A configuration update parses two fields and changes the first before parsing the second. Propose a failure policy that preserves the previous configuration.
Show worked solution
Parse and validate both fields into local candidate values first. Only after both succeed should the operation replace the stored configuration in one deliberate commit phase. Return a structured error if either field fails. Verify that malformed second input leaves both previous fields unchanged, because checking only the returned error would miss partial mutation.
Key takeaways
- Use structured error values for expected failure.
- Propagation and recovery belong to different decisions.
- Test failure state as carefully as success values.