Expand description
Overview
Outcome<S, M, F>
is an augmentation of the Result
type
found in the Rust standard library.
It is an enum with the variants
Success(S)
, representing success and containing a valueMistake(M)
, representing an optionally retryable error and containing a valueFailure(F)
, representing failure and containing a value.
enum Outcome<S, M, F> {
Success(S),
Mistake(M),
Failure(F),
}
Outcome
is an augmentation to Result
. It adds a third state to
the “success or failure” dichotomy that Result<T, E>
models.
This third state is that of a soft or retryable error. A retryable
error is one where an operation might not have succeeded, either due to
other operations (e.g., a disk read or write not completing),
misconfiguration (e.g., forgetting to set a specific flag before calling a
function), or busy resources (e.g., attempting to lock an audio, video, or
database resource).
use outcome::prelude::*;
#[derive(Debug, PartialEq)]
enum Version { V1, V2 }
#[derive(Debug, PartialEq)]
struct EmptyInput;
fn parse_version(header: &[u8]) -> Outcome<Version, EmptyInput, &'static str> {
match header.get(0) {
None => Mistake(EmptyInput),
Some(&1) => Success(Version::V1),
Some(&2) => Success(Version::V2),
Some(_) => Failure("invalid or unknown version"),
}
}
let version = parse_version(&[]);
assert_eq!(version, Mistake(EmptyInput));
Usage
At this time, the name outcome
is already taken on crates.io. As
crates.io does not yet support namespaces or collections, we’ve had to
take a unique approach to still publish the crate. To do this, we’ve
generated a UUIDv5
string via python:
from uuid import *
print(uuid5(uuid5(NAMESPACE_DNS, "occult.work"), "outcome"))
This should generate the string 46f94afc-026f-5511-9d7e-7d1fd495fb5c
.
Thus the dependency in your Cargo.toml
will look something like:
[dependencies]
outcome-46f94afc-026f-5511-9d7e-7d1fd495fb5c = "*"
However, the exported library is still named outcome
, so importing it is
treated the same:
use outcome::prelude::*;
Users can also work around this by using the package
key in their
dependency declaration:
[dependencies.outcome]
version = "*"
package = "outcome-46f94afc-026f-5511-9d7e-7d1fd495fb5c"
Is this solution friendly to users? No, but neither is the lack of namespacing nor a squatting policy on crates.io. If/when this problem is resolved, this crate’s documentation (and name!) will be changed and all versions will be yanked.
Features
There are several features available to the crate that are disabled by default. These include:
unstable
(Enable “unstable” functions that mirror unstable functions found inResult
. UnlikeResult
, however, a nightly compiler is not required.)nightly
(Enable features that require the nightly rust compiler to be used, such asTry
)report
(Enable conversion fromAberration
to aneyre::Report
)diagnostic
(Enable conversion fromAberration
to amiette::Report
)
Users can also enable no_std
support by either setting default-features
to
false
or simply not listing std
in the list of features. Lastly, the
report
and diagnostic
features should be treated as mutually exclusive as
they both export a similar interface that is similar enough to cause
collisions. If the author of eyre
ever manages to make it no_std
(there are
limitations preventing Error
from being no_std
), these features will be
redesigned such that report
+ std
uses miette
, and report
+ no_std
uses eyre
.
nightly
will enableunstable
.report
will enablestd
.diagnostic
will enablestd
.
NOTE: Due to limitations with cargo features, we cannot actually enforce mutual exclusivity, and support building the crate with documentation and tests with all features enabled.
no_std
Nearly every single feature in outcome
supports working with #![no_std]
support, however currently eyre
does require std
support (Attempts
were made at making no_std
work, but this was removed and has not been
available for some time).
[dependencies.outcome]
package = "outcome-46f94afc-026f-5511-9d7e-7d1fd495fb5c"
version = "..."
features = ["nightly"]
unstable
When enabled, the unstable
feature provides several associated methods for
Outcome
that mirror unstable APIs found in Result<T, E>
. If
the methods mirrored are changed in any future releases of stable rust, these
will as well. Additionally, if any of the APIs are stabilized, they will be
moved out of this feature and into the default feature set. Unlike the
nightly
feature, these APIs can be implemented in stable rust.
nightly
The nightly
feature set also requires a nightly toolchain. This is detected
in outcome’s build.rs
script via the
rustversion crate. While users can
enable the nightly feature on a stable toolchain, nothing additional will
be compiled.
Once available, users will have to enable specific nightly features for each API set mentioned. These are listed below.
#![feature(try_trait_v2)]
— operator?
supportOutcome
may be used with operator?
, including from functions that return aResult<T, E>
, as long as:E: From<Outcome::Failure>
#![feature(never_type)]
— APIs that return!
Outcome
will have several functions where the!
type is used in the function signature. These includeinto_success
, and others.- Several stable functions that return an
Aberration
will instead return anOutcome<!, M, F>
.
#![feature(termination_trait_lib)]
— Exit process with anOutcome
- NOTE: This requires the
std
feature to be enabled as well. - In addition to being usable with
fn main()
, any unit test may return anOutcome
directly. This works in the same way as returning aResult<T, E>
- NOTE: This requires the
report
The report
feature adds the WrapFailure
trait to both Outcome
and
Aberration
. This trait is meant to mimic the WrapErr
trait found on
Result<T, E>
that is provided by eyre
. Therefore, a blanket
implementation is provided for all types that implement WrapErr
. However,
to stay in line with outcome
’s naming convention, instances of err
have
been replaced with failure
.
Why Augment Result<T, E>
?
Outcome
is not intended to fully replace Result
, especially at
the API boundary (i.e., the API used by clients) when there is a clear
success or failure state that can be transferred to users. Instead, it
provides the ability to quickly expand the surface area of consumed APIs
with finer grained control over errors so that library writers can write
correct behavior and then return at a later time to compose results,
expand error definitions, or to represent different error severities.
As an example, the section making unhandled errors unrepresentable in the post Error Handling in a Correctness-Critical Rust Project, the author states:
this led me to go for what felt like the nuclear solution, but after seeing how many bugs it immediately rooted out by simply refactoring the codebase, I’m convinced that this is the only way to do error handling in systems where we have multiple error handling concerns in Rust today.
The solution, as they explain in the next paragraph, is
make the global
Error
enum specifically only hold errors that should cause the overall system to halt - reserved for situations that require human intervention. Keep errors which relate to separate concerns in totally separate error types. By keeping errors that must be handled separately in their own types, we reduce the chance that the try?
operator will accidentally push a local concern into a caller that can’t deal with it.
As the author of this post later shows, the sled::Tree::compare_and_swap
function returns a Result<Result<(), CompareAndSwapError>, sled::Error>
.
They state this looks “way less cute”, but will
improve chances that users will properly handle their compare and swap-related errors properly[sic]
// we can actually use try `?` now let cas_result = sled.compare_and_swap( "dogs", "pickles", "catfood" )?; if let Err(cas_error) = cas_result { // handle expected issue }
The issue with this return type is that there is technically nothing to
stop a user from using what the creator of the outcome
crate calls the
WTF operator (??
) to ignore these intermediate errors.
let cas = sled.compare_and_swap("dogs", "pickles", "catfood")??;
It would be hard to forbid this kind of usage with tools like clippy due to libraries such as nom relying on nested results and expecting moderately complex pattern matching to extract relevant information.
Luckily, it is easier to prevent this issue in the first place if:
- An explicit call to extract an inner
Result<T, E>
-like type must be made - The call of an easily greppable/searchable function before using the
“WTF” (
??
) operator is permitted. - The
Try
trait returns a type that must be decomposed explicitly and does not support the try?
operator itself.
Thanks to clippy’s
disallowed_method
lint, users can rely on the first two options until
Try
has been stabilized.
outcome
provides this in the form of its Concern
type, whose variants
match the Success
and Failure
of Outcome
, as well as the associated
function Outcome::acclimate
, which returns a Result<Concern<S, M>, F>
.
NOTE: This associated function will be deprecated once Try
has been
stabilized.
State Escalation
Using the parse_version
example seen earlier in our documentation, we can see
that the Version
enum can return either V1
or V2
. At some point, a tool
parsing some version number might choose to deprecate support for V1
. In
these instances, a developer might choose to turn V1
into a Mistake
.
However, parse_version
returns a Success(V1)
. How, then, can we turn this
into a Mistake
in-place?
This is where state escalation comes into play. It is one of the more
experimental features that outcome
is trying to experiment with. The basic
idea of this concept is a successful outcome for one function does not imply
that the caller considers the successful value valid enough to continue.
With state escalation, we not only move the possible state of an Outcome
from Success
to Mistake
or Mistake
to Failure
, but we are also able to
eliminate possible states from the Outcome
. That is, given an Outcome<S, M, F>
, the first state escalation would return an Outcome<!, M, F>
(on
stable, this returns an Outcome<Infallible, M, F>
. A second state
escalation would result in an Outcome<!, !, F>
. This allows for fast state
transitions with little to no effort, while keeping this state transition
separate from mapping operations. The benefit of reducing the possible set of
states that the Outcome
can represent is simply a side effect of Rust’s
powerful type system with regards to enum
s and their variants.
It is important to note that there is no de-escalation of state possible
without explicit operations from users. In other words, a new Outcome must be
generated when one or more fields are Infallible
. This is difficult to
enforce without certain features missing from Rust, specifically partial
specialization and impl
overloading (e.g., an Outcome<!, M, F>
should not
be allowed to have and_then
called on it). However this same limitation is
found in Result
and other variants, and even Finite State Machines
implemented with Rust enum
s will suffer the same fate. In an ideal world,
Outcome’s state escalation would only permit unidirectional transitions, and
would require the creation of a completely new Outcome
if a user wanted to
reset or de-escalate the Outcome
. This might not ever be possible to do or
enforce in Rust, so in the meantime it is up to users to behave themselves with
regards to escalating state.
Modules
Traits for retryable conversions between types.
diagnostic
Support for the miette
crate.
The Outcome Prelude
Structs
Enums
Traits
An attempted conversion that consumes self
, which may or may not be
expensive. Outcome’s analogue to TryInto
.