This approach is a bit extreme, but, I think, worth exploring.
Relevant topics:
UPD: If you don’t want to read the whole discussion I recommend to check this @Centril’s comment.
Summary
Introduce try fn
and throw
/fail
/etc. keyword (throw
will be used here for now), which will allow us to write the following code:
try fn foo() -> Result<u64, MyError> {
// returns Ok(10), i.e. happy path is Ok autowrapped
if early_return() { return 10; }
// returns `Err(MyError)`
if is_error() { throw MyError; }
// `?` works as well
check_another_error()?;
1
}
try fn bar() -> Result<(), MyError> {
if is_error() { throw MyError; }
// Good buy `Ok(())`!
}
Additionally try fn
will work with Option
and even custom types which will implement TryOk
and TryThrow
.
How it will work
Instead of Try
trait we will have the following two traits:
trait TryOk<T> {
fn from_ok(val: T) -> Self;
}
trait TryThrow<T> {
fn from_throw(val: T) -> Self;
}
And Result
will have the following implementations:
impl<T, E> TryOk<T> for Result<T, E> {
fn from_ok(val: T) -> Self { Ok(val) }
}
impl<T, E, V: Into<E>> TryThrow<V> for Result<T, E> {
fn from_throw(val: V) -> Self { Err(val.into()) }
}
Now this code:
try fn foo() -> Result<u64, MyError> {
if is_error() { throw MyError; }
1
}
Will be desugared into:
fn foo() -> Result<u64, MyError> {
if is_error() { return TryThrow::from_throw(MyError); }
TryOk::from_ok(1)
}
For Option
trait implementations will look like:
impl<T> TryOk<T> for Option<T> {
fn from_ok(val: T) -> Self { Some(val) }
}
impl<T> TryThrow<()> for Option<T> {
fn from_throw(_val: ()) -> Self { None }
}
Why generic type in traits instead of the associated one?
For Result
and Option
generic type in the Try
traits is clearly redundant, but with custom types it can lead to boilerplate reduction and ergonomic improvements. For example this can be in a crate:
enum Record {
Int(i64),
Str(String),
Error(MyErr),
None,
}
impl TryOk<i64> for Record {
fn from_ok(val: i64) -> Self { Record::Int(val) }
}
impl TryOk<String> for Record {
fn from_ok(val: String) -> Self { Record::Str(val) }
}
impl TryOk<i64> for Record {
fn from_ok(val: i64) -> Self { Record::Int(val) }
}
impl<E, T: Into<E>> TryThrow<E> for Record {
fn from_throw(val: T) -> Self { Record::Err(val.into()) }
}
impl TryThrow<()> for Record {
fn from_throw(_val: ()) -> Self { Record::None }
}
And this in user code:
try fn foo(data: &[u8]) -> Record {
if data.len() == 0 { throw; } // returns `Record::None`
if let Err(e1) = check(data) { throw e1; } // e1 converted to MyErr
if let Some(s) = parse_str(data) { return s; } // returns `Record::Str(s)`
if let Some(n) = parse_int(data) { return n; } // returns `Record::Int(n)`
throw "failure message";
}
There is (initially unintended) side-effect: type can implement only TryOk
trait, e.g. like this:
impl<T: Into<Foo>> TryOk<T> for Foo {
fn from_ok(val: T) -> Self { val.into() }
}
// no `TryThrow` implementations for `Foo`
Which will make try fn
s returning Foo
“auto-into” functions:
// converts `1` and `"ok"` into `Foo` automatically
try fn foo1() -> Foo {
if true { return 1; }
return "ok";
}
try fn foo2() -> Foo {
throw; // compilation error: `Foo` does not implement `TryThrow` trait
}
Drawbacks
- Overly-flexibile for the main use-cases (
Result
andOption
). - Can lead to surprising conversions behind the scenes.
- “Auto-into” variant is a bit surprising usage of
try fn
.
Unresolved questions
Bikeshed of trait and keyword names
This feature is closely related to the throw
RFC and has the same choices for keywords. Subjectively throw MyError;
looks more natural than fail MyError;
, thus I think it’s a slightly better option, even considering unwanted relation to exceptions.
Special keyword for “happy” return
It was proposed to add pass
keyword to the language, instead of doing autowrapping returns. The big disadvantages are:
- Additional keyword
- Return of
Ok(())
More magic in trait implementations
There is an open question of how far we want to go with traits implementations for Result
and Option
, e.g. the following implementation can be added:
impl<T, E, V: Into<T>> TryOk<V> for Result<T, E> {
fn from_ok(val: V) -> Self { Ok(val.into()) }
}
Personally I think such implementations are unnecessary, and can result in too much confusion, compared to potential ergonomic improvements.
Relation to try
block
Try blocks are closely related to try fn
s, thus ideally we would like to be able to think about try fn foo() -> T { .. }
as a short-cut for fn foo() -> T { try { .. } }
.