Introduction

For the last few days the rust community has been abuzz with renewed discussions about try syntax in response to a series of blog posts([1][2]) by @withoutboats. This has triggered some fairly intense bikeshedding and vitriolic objections and, it seems, some progress!

As of yesterday the lang team is moving forward with a proposal to resolve issues with try blocks, and presumably, begin stabilizing them. It seems that the lang team is arriving at consensus to merge try blocks, where as historically this has been blocked on disagreements about ok-wrapping. So what changed? Two things, first, it became apparent that try blocks and ok wrapping could be separated from proposals for try functions and throws syntax. And second, a comment on reddit compellingly compared try and async blocks as similar effects applied to blocks.

Background

Currently, fallibility in rust is expressed with the Result enum. This works like any other enum, in that you have to construct each variant manually, with results this is known as Ok-wrapping.

fn foo() -> Result<PathBuf, io::Error> {
    let base = env::current_dir()?;
    Ok(base.join("foo"))
}

For a while now there have been proposals to introduce fallible functions, also known as try functions or throws syntax. These proposals have historically looked a little like this:

fn foo() -> PathBuf throws io::Error {
    let base = env::current_dir()?;
    base.join("foo")
}

Separating Ok-Wrapping From Try Functions

Ok-wrapping and try functions are often bundled together in proposals about either, but this doesnt have to be the case. In fact, on nightly rust it’s been possible to enable try blocks (with Ok-wrapping) for a while. You can go on nightly right now and write the previous example like this:

#![feature(try_blocks)]

fn foo() -> Result<PathBuf, io::Error> {
    try {
        let base = env::current_dir()?;
        base.join("foo")
    }
}

It turns out that separating the two proposals from each other allowed the language team to come to consensus on try blocks and narrow the disagreements to just function-level try. It’s not the syntax sugar of wrapping returns that blocked the try block proposal, it was the idea that this would lead immediately to Result being stripped from the return type. And, when you dig deeper into ok-wrapping as a block level effect, it starts to lead to some attractive symmetry in the language.

Comparison To Async

The previously mentioned comment does a much better job explaining this than I can but it boils down to this snippet:

       try { x? } == x == (try{ x })?
async { x.await } == x == (async{ x }).await

The idea is that async {} and await cancel eachother out, and the same is true for try {} and ?. When framed this way you can start to see try and async as effects applied to blocks, rather than seeing them as just try blocks or async blocks.

What I’d Like to See Next

To start we gotta stabilize try blocks. This is already in progress so there’s not much to add on this point other than bikeshedding which keyword, raise, pass, fail, throw, or yeet should be used to return errors within the try block. And I dont want to get into that, it is not the point of this post.

However, once we have try blocks I imagine I’m not going to love having to put a try block and indent level around the full body of every function that returns a Result. To deal with this I would like to propose adding support for annotating the function body block itself with try. This is distinct from function level try, which to me is the try equivalent of async fn, and would be written as try fn. Also, these proposals are not mutually exclusive. With this change the above example becomes:

fn foo() -> Result<PathBuf, io::Error> try {
    let base = env::current_dir()?;
    base.join("foo")
}

We’ve essentially just removed a redundant block around the function body. Now we already get one of the major benefits of withoutboats’ proposal, we don’t have to edit every return location to wrap it with a Result when we introduce an error return channel. However the Result is still visible, and the function signature is effectively unchanged. You can think of the function signature as outside the “Result monad” or “try effect” while the function body block itself is inside.

Going back to the comparison for async, I don’t see why we couldn’t extend this change to also apply to async blocks. I can’t think of any particular reasons why this would be desirable other than language consistency, but given the intensity of the discussion over whether or not async fns should hide the Future from the return type I’m guessing there are at least some reasons to allow this.

async fn foo() -> i32 {
    bar().await
}

Could be also then be written as:

fn foo() -> impl Future<Output = i32> async {
    bar().await
}

In this version of rust async and try would effectively become block effects and act like do notation for their respective monads. Now, we could stop here, and only allow block effects in places where you couldn’t already put an expression, function body blocks and bare blocks. Or we can go for maximum consistency and allow block effects in any expression with a block:

let d = if s.is_empty() try {
    let a = b?;
    a + c
} else {
    Err(...)
}?;

// Already valid
let _ = || try { ... };
let _ || async { ... };

match foo try {
    ...
}

let c = loop try {
    ...
}?;

We could do the same thing with unsafe, and treat it like a block effect. Fun fact, rust is currently moving away from unsafe functions having an unsafe body block by default, and this generalization could go hand in hand with that change.

// with new rfc body is safe by default
unsafe fn foo() {
    ...
}

// new way to get an entirely unsafe body
unsafe fn foo() {
    unsafe {
        ...
    }
}

// same unsafe body with generalized block effects
unsafe fn foo() unsafe {
    ...
}

Or even treat expressions like if, match and loop as block effects as well:

fn foo(bar: Bar) -> Baz match bar {
    Bar::Quix(q) => ...,
    ...
}

for bar in bars match bar {
    Bar::Quix(q) => ...,
    ...
}

if is_empty loop try {
};

// these examples could go on for QUITE a while...

We treat the keywords in fn, if, match, loop, async, unsafe, and try as composable effects applied to blocks. This lets us concisely and intuitively compose types and control-flow via syntax sugar.

And, the nice thing about this symmetry is that it helps neutralize a lot of people’s concerns around rust getting “too big”. We’re not making it bigger, we’re making it more consistent!

Finally, this leaves room for discussions over whether or not there should also be a try effect equivalent to async fn, where the function definition itself also exists within the monad, similar to the original throws function proposals. And, it remains to be seen whether or not any of this can actually parse given existing stable syntax, but I hope others agree with me that this generalization is worth digging into.

Conclusion

With all three of these steps you would be able pick and choose how you want to handle control flow with Try types. A single keyword enables the effect, and you have a slew of scopes at which you can apply it. The similarity between async effects and try effects would make both of them easier to teach. Once you’re familiar with one of the effects the other should come intuitively.