EN · · 4 min read
Result Is a Control-Flow Contract
A practical Rust mental model for recoverable failure, the question behind panic, and what the question-mark operator actually preserves.
Technical GuideIntermediateEvergreen
Revised
Historical note: the error-handling module used here was implemented in
learn-ruston June 7, 2026. This LoC editorial pass was written on August 23. The examples are learning artifacts and make no claim about production use.
Result<T, E> is often introduced as Rust’s alternative to exceptions. The
comparison is useful for orientation and too shallow as a mental model. Result
does not merely choose different syntax for failure. It makes recoverable
failure part of the function’s ordinary input/output protocol.
fn parse_port(input: &str) -> Result<u16, std::num::ParseIntError> {
input.parse::<u16>()
}
Without reading the body, a caller knows that parsing can fail, what success contains, and which error type carries the failure. The compiler then requires the caller to preserve, transform, or deliberately terminate that branch.
Result changes the conversation at the boundary
Consider a configuration reader:
use std::{fs, io};
fn read_config(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?;
Ok(contents)
}
The signature says that a missing file, denied permission, or other I/O error is not automatically a program bug. It is an outcome the caller may interpret. The function does not decide whether to retry, use defaults, show a message, or stop the application. It returns enough information for the layer that owns that policy.
This separation is an architectural benefit. Low-level code reports what happened; higher-level code decides what it means for the use case.
The question-mark operator is not hidden recovery
The ? operator is sometimes described as shorthand for error handling. More
precisely, it is shorthand for propagation. For a Result, it unwraps the
success value or returns early with a compatible error.
The following versions have the same essential control flow:
fn explicit(path: &str) -> Result<String, std::io::Error> {
let contents = match std::fs::read_to_string(path) {
Ok(value) => value,
Err(error) => return Err(error),
};
Ok(contents)
}
fn propagated(path: &str) -> Result<String, std::io::Error> {
let contents = std::fs::read_to_string(path)?;
Ok(contents)
}
? does not log, retry, replace, or explain the error. It preserves the current
function’s contract while removing a branch whose only purpose is forwarding.
If the surrounding function cannot return a compatible residual type, the code
does not compile. That constraint is the point.
Panic answers a different question
The useful distinction is not “errors are bad, panics are worse.” Ask whether the caller can reasonably recover.
- Return
Resultfor expected environmental or input failures. - Panic when continuing would violate an internal invariant or when a bug has made the program’s assumptions false.
Library code should be especially careful. A library that panics on malformed input removes policy from its caller. The application may have preferred to reject one request, skip one record, or present validation feedback.
unwrap and expect are therefore decisions, not merely convenience methods.
They can be appropriate in tests, examples, initialization after validation, or
states made impossible by construction. Each use should be able to answer why
failure is genuinely unrecoverable at that point.
Design the error for its audience
Returning Result does not guarantee a useful API. An error type can still be
too vague, leak implementation details, or make recovery impossible.
At a boundary, consider:
- Which failures can callers distinguish usefully?
- Which source errors should be retained for diagnosis?
- Which details are safe to expose?
- Does the caller need structured fields or only a human-readable message?
- Can one operation accumulate several validation failures, or must it stop at the first one?
An application error enum can preserve these categories explicitly:
enum ConfigError {
Read(std::io::Error),
InvalidPort(std::num::ParseIntError),
MissingField(&'static str),
}
The example is intentionally incomplete — it would need display and source implementations for a polished library — but it shows the direction. The error model should speak in terms meaningful at its boundary.
A careful comparison with C#
C# can express explicit result values, and Rust code can still panic. Neither language forces one universal architecture. The default paths differ:
| Concern | Typical C# exception flow | Rust Result flow |
|---|---|---|
| Visibility in signature | often implicit | explicit in return type |
| Propagation | automatic until caught | explicit with ? or matching |
| Recovery policy | catch boundary | match/combinator boundary |
| Unhandled failure | process or request boundary behavior | returned error must be handled; panic is separate |
The lesson is not that every C# method should return a custom result wrapper. It is to notice where a failure is part of the domain protocol rather than an unexpected runtime escape.
Tests should cover policy, not only variants
The learning examples show successful and failed parsing and file reads. A more complete test suite should verify:
- valid input returns the expected value;
- invalid input retains a useful error category;
- missing files propagate rather than panic;
- conversion between lower- and higher-level errors preserves the source;
- deliberate invariant violations panic only where the contract says they do.
The tests make the boundary executable. They tell future readers which failures are data and which represent broken assumptions.
The transferable lesson
Result is valuable because it makes responsibility visible. A function owns
detecting a failure; a caller may own interpreting it; an application boundary
owns the final response. The ? operator keeps the success path readable
without erasing that chain of responsibility.
That mental model transfers beyond Rust. Reliable software improves whenever failure semantics are named at the boundary instead of being discovered only after control flow has escaped it.