Option vs. Result in Rust
In this post
- Ask whether absence is normal
- Keep optional parsing fallible
- Convert only when the caller changes the meaning
- Keep errors when they matter
- Let
?propagate one kind of outcome - Collect many values deliberately
- Use an enum for named states
A configuration value can be missing, or it can be present but wrong. If an optional port is not configured, the program can use its default. If the configuration contains PORT=fast, silently using the default hides a mistake.
Rust has two enums for keeping this distinction visible. Option<T> represents a value that may be absent. Result<T, E> represents an operation that succeeds or returns an error. The useful question is whether absence is an expected state or shows that the operation could not complete.
The previous post on boundary parsing turned external text into validated values. This post continues at the next decision: what should a function say when an input is missing, malformed, or valid?
Ask whether absence is normal
Suppose settings are stored as key-value pairs. Looking up a key can find no value, so the lookup naturally returns Option. Option::map applies a function when the option is Some; here it turns the stored &String into a string slice.
use std::collections::HashMap;
type Settings = HashMap<String, String>;
const PORT_KEY: &str = "PORT";
fn lookup<'a>(settings: &'a Settings, key: &str) -> Option<&'a str> {
settings.get(key).map(String::as_str)
}
The lifetime 'a says that the returned &str borrows from the settings map and cannot outlive it. Some(value) means the key was present. None means the lookup completed normally but found no value.
Parsing the value asks another question. The string is present, but it may not contain a valid number. text.parse::<u16>() returns Ok(8080) for valid text and Err(error) when parsing fails. The error can distinguish an invalid character from a number that does not fit in u16.
Use Option when “not found” is a normal result. Use Result when the caller needs to know why the operation did not produce its value. A function that returns Option<u16> for a malformed port makes “missing” and “invalid” indistinguishable.
Keep optional parsing fallible
An optional setting has both properties: the key may be absent, and a present value may fail to parse. Mapping the parser over the lookup would produce Option<Result<u16, ParseIntError>>: None for absence, Some(Ok(value)) for valid text, and Some(Err(error)) for invalid text.
The standard Option::transpose method changes this to the more useful Result<Option<u16>, E> shape:
fn optional_port(
settings: &Settings,
key: &'static str,
) -> Result<Option<u16>, std::num::ParseIntError> {
lookup(settings, key)
.map(str::parse::<u16>)
.transpose()
}
The three outcomes are now Ok(None), Ok(Some(8080)), and Err(error). transpose preserves all three; it puts the fallible operation on the outside so ? can return its error while None remains a normal value.
A boundary often needs to add context to an error. This small enum names the missing setting and retains the lower-level parse error:
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Missing { key: String },
InvalidPort { key: &'static str, source: ParseIntError },
}
fn optional_port_with_context(
settings: &Settings,
) -> Result<Option<u16>, ConfigError> {
lookup(settings, PORT_KEY)
.map(|text| {
text.parse::<u16>().map_err(|source| {
ConfigError::InvalidPort {
key: PORT_KEY,
source,
}
})
})
.transpose()
}
map_err changes only the error side of a Result. The closure
In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scoped name binding in a language with first-class functions. Operationally, a closure is a record storing a function together with an environment. The environment is a mapping associating each free variable of the function with the value or reference to which the name was bound when the closure was created. Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.Read on Wikipedia → passed to it adds the setting name. A small error enum close to the boundary is usually enough; add Display and Error when this type becomes part of a public API, and add a wider abstraction only when several boundaries need the same contract.
Convert only when the caller changes the meaning
The same missing setting can be acceptable to one caller and required by another. Keep the optional function honest, then convert None where absence becomes an error:
fn required_port(settings: &Settings) -> Result<u16, ConfigError> {
optional_port_with_context(settings)?
.ok_or(ConfigError::Missing {
key: PORT_KEY.to_owned(),
})
}
Option::ok_or changes Some(value) into Ok(value) and None into Err(error). The argument to ok_or is built immediately, even when the option is Some. Here that allocates a String on every call.
Use ok_or_else when building the error should happen only for None:
fn required_port_lazily(settings: &Settings) -> Result<u16, ConfigError> {
optional_port_with_context(settings)?
.ok_or_else(|| ConfigError::Missing {
key: PORT_KEY.to_owned(),
})
}
The same choice exists between unwrap_or and unwrap_or_else. A caller that wants a default only for an absent setting can write:
fn port_or_default(settings: &Settings) -> Result<u16, ConfigError> {
Ok(optional_port_with_context(settings)?.unwrap_or(8080))
}
unwrap_or(8080) supplies a value for None. The ? before it still reports an invalid configured port.
Keep errors when they matter
Result::ok() converts Result<T, E> into Option<T> by turning Err(error) into None. A Result has no absent case. In this function, a missing key becomes None from the ? on lookup, while an invalid value becomes None from ok():
fn lossy_port(settings: &Settings) -> Option<u16> {
lookup(settings, PORT_KEY)?.parse::<u16>().ok()
}
The caller can no longer distinguish a missing port from PORT=8O8O. Use .ok() only when every failure really has the same meaning as absence, such as treating an unreadable optional cache entry as a cache miss.
The same problem appears with a sentinel valueIn computer programming, a sentinel value is a special value in the context of an algorithm which uses its presence as a condition of termination, typically in a loop or recursive algorithm.Read on Wikipedia → such as 0 or -1. The earlier post on modeling data with Rust’s type system explains why a special value can confuse a caller about what the result means.
Let ? propagate one kind of outcome
The ? operator returns early from a Result function when it sees Err, and from an Option function when it sees None.
It does not automatically convert Option into Result or Result into Option. The caller chooses that meaning explicitly with ok_or, ok_or_else, or ok. This keeps a missing value from becoming an arbitrary error and keeps a meaningful error from disappearing as None.
Collect many values deliberately
An iterator of Result values can be collected into one Result:
use std::net::IpAddr;
fn parse_addresses(
values: &[&str],
) -> Result<Vec<IpAddr>, std::net::AddrParseError> {
values.iter().map(|text| text.parse()).collect()
}
This is a short-circuitingShort-circuit evaluation, minimal evaluation, or McCarthy evaluation is the semantics of some Boolean operators in some programming languages in which the second argument is executed or evaluated only if the first argument does not suffice to determine the value of the expression: when the first argument of the AND function evaluates to false, the overall value must be false; and when the first argument of the OR function evaluates to true, the overall value must be true.Read on Wikipedia → operation: it stops at the first error. That is useful when one invalid address makes the whole configuration unusable.
Collecting into Option<Vec<T>> follows the same pattern, but a single None carries no reason for the missing value. If a form should show all invalid fields at once, use a loop that stores each error instead of relying on short-circuiting.
Use an enum for named states
Nested enums become difficult to read when the domain has three named states. A partial update is a common example, also covered in the earlier type-system and data-modeling post:
enum Update<T> {
Keep,
Clear,
Set(T),
}
Keep means that the caller did not ask to change the field. Clear removes its current value. Set(value) replaces it. This is clearer than Option<Option<T>>, where the reader has to remember which None means what.
Use a custom enum when the domain has several named outcomes. Use Option or Result when those standard types already express the contract clearly; they work directly with ? and the standard library methods.
When verifying this code, check three cases: a missing PORT produces Ok(None), a valid PORT produces Ok(Some(value)), and an invalid PORT produces Err. The required and default callers then show how the same optional result can be given different meanings without changing the parser.