RFA-722 · Case file with fixtures · Case 694 of 694 · Runtime evidence
bool::ok_or Constructs Its Error Even When the Condition Is True
Rust evaluates the error argument before calling bool::ok_or. Use ok_or_else when creating the error is expensive or observable, because its closure runs only when the boolean is false.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all targets
- Profiles
- dev, release
Direct answer
What this Rust failure means
- Why it happens
- Method arguments are evaluated before the call, so bool::ok_or receives an error value which already exists; only bool::ok_or_else can defer construction behind a closure.
- First discriminating check
- Instrument the error constructor and test true and false separately, then use ok_or_else when construction is expensive or observable and an explicit branch when side effects are policy.
Rust 1.98 added a convenient way to convert a boolean condition into a Result. A true condition becomes Ok(()), while a false condition becomes an error. The short form can still do work on the successful path which is easy to miss.
This program returns Ok(()), but it also builds the error:
use std::cell::Cell;
fn build_error(constructions: &Cell<u32>) -> String {
constructions.set(constructions.get() + 1);
"feature is disabled".to_owned()
}
fn main() {
let constructions = Cell::new(0);
let result = true.ok_or(build_error(&constructions));
assert_eq!(result, Ok(()));
assert_eq!(constructions.get(), 0); // panics: the value is 1
}
The boolean did not fail. The surprising part is that build_error ran before ok_or could inspect that boolean.
The failing fixture makes this visible with a counter on Rust 1.98.1. The repaired fixture proves both branches: a true condition constructs no error with ok_or_else, and a false condition constructs exactly one.
ok_or receives a value, not a recipe
The relevant signatures describe the difference:
pub fn ok_or<E>(self, err: E) -> Result<(), E>
pub fn ok_or_else<E, F>(self, f: F) -> Result<(), E>
where
F: FnOnce() -> E
ok_or needs an E as its argument. If I write build_error(), Rust must evaluate that expression to produce the E before the method is called. Only then can ok_or return Ok(()) and discard the already-created error.
ok_or_else receives a closure. The closure is a recipe for creating E, so the method can first inspect the boolean. It calls the recipe only for false:
let result = condition.ok_or_else(|| build_error());
This is ordinary function-call evaluation, not a special defect in bool. The same reasoning applies when an API offers value and closure variants such as Option::ok_or and Option::ok_or_else, or bool::then_some and bool::then.
I use one simple reading rule: an argument like make_value() is work now; an argument like || make_value() is work the callee may choose to do later.
The result does not reveal the unnecessary work
Looking only at result hides the failure:
let result = true.ok_or(load_detailed_error());
assert_eq!(result, Ok(()));
The assertion passes. A test which checks only the return value says everything is correct. But load_detailed_error may format a large message, clone owned context, capture a backtrace, read configuration, increment a metric, or write a log. Its value is then dropped unused.
This is why the case belongs in a failure atlas even though the compiler reports no error. The type and value are correct, yet the runtime behavior can be wrong for the application's performance or observability contract.
For a literal or cheap enum variant, eager construction is often fine:
is_ready.ok_or(StateError::NotReady)?;
There is no reason to turn every small error into a closure. I choose ok_or_else when construction has meaningful cost, owns data, or has any observable effect:
is_ready.ok_or_else(|| StateError::NotReady {
resource: resource_name.clone(),
detail: format!("attempt {attempt} did not become ready"),
})?;
The closure keeps the clone and formatting on the error path.
Error construction should normally be boring
The counter in the fixture is deliberate because it proves evaluation. It is not a recommendation to put mutation inside error builders.
I prefer error construction to be free of important side effects. A log written while creating an error can become a false incident when the surrounding operation actually succeeds. A metric increment can overcount failures. A retry token consumed there may change behavior. ok_or_else fixes when the construction runs, but keeping policy outside the constructor makes the code easier to reason about.
When a side effect is part of the real failure policy, I often make the branch explicit:
if !condition {
record_rejection();
return Err(build_error());
}
This is longer, but it tells the reader that recording is intentional. Combinators are useful when they make the data flow clearer; they are not a requirement.
This conversion keeps () on the success side
bool::ok_or and bool::ok_or_else produce Result<(), E>, not Result<bool, E>. Once the condition is true, the boolean itself carries no additional information. The unit value says only that the condition passed.
This fits validation and guard code:
fn require_capacity(remaining: usize) -> Result<(), String> {
(remaining > 0).ok_or_else(|| {
format!("no capacity remains: {remaining}")
})
}
If the success path needs to return data, a boolean-to-unit conversion may be the wrong abstraction. I may use if, match, or validate the value directly. The new method makes a guard compact; it does not replace domain modelling.
There is also no asynchronous short-circuit hidden here. Calling an ordinary function inside ok_or(...) runs that function before the method. Creating an async future may only create the future without polling it, but allocation or other work performed while constructing that future can still happen. I inspect the actual expression rather than assuming that “async” makes eager evaluation harmless.
How I test this boundary
For a lazy error path, I test behavior as well as return values:
- A true condition returns
Ok(()). - A true condition does not invoke the error closure.
- A false condition returns the expected error.
- A false condition invokes the closure exactly once.
- Expensive context is cloned or formatted only in the false branch.
A counter, mock, or deliberately panicking closure can prove laziness in a small unit test. The counter is usually the clearest because it can verify both zero calls and one call without making the test depend on panic handling.
The core principle goes beyond this method: distinguish a value from a computation which can produce a value. bool::ok_or accepts an error that already exists. bool::ok_or_else accepts a computation and controls whether it runs. When success is common and the error is not trivial, that one closure keeps failure work on the failure path.