As a disclaimer, this isn't my personal proposal or anything. But I took a look at the RFC for try blocks in Rust, and I thought it was interesting how it it left the door open for catch blocks in the future. I thought it would be an interesting idea to share. But in order to talk about this, we have to work our way forwards.
Above the Iceberg: Rust enums
Many of you readers will already understand this much, but if you've never worked with Rust before, I'll have to introduce the enum system. Rust's enums can carry values on particular variants. They're pretty much just tagged or discriminated unions.
pub enum JsonValue { Null, Boolean(bool), Number(f64), String(String), Array(Vec<JsonValue>), Object(Vec<(String, JsonValue)>), }
There are a few ways we can get the inner value. We can use a match expression, which is a bit like switch in other languages.
fn to_json(value: JsonValue) -> String { match value { JsonValue::Null => "null".to_string(), JsonValue::Boolean(b) => b.to_string(), JsonValue::Number(n) => n.to_string(), JsonValue::String(s) => format!("\"{s}\""), JsonValue::Array(a) => { let mut builder = String::from("[ "); for element in a { builder.push_str(&to_json(element)); builder.push_str(", "); } builder.push(']'); builder } JsonValue::Object(o) => { let mut builder = String::from("{ "); for (key, value) in o { builder.push_str(&format!("\"{key}\": {}", to_json(value))); builder.push_str(", "); } builder.push('}'); builder } } }
We can use if let to match on only one particular variant.
fn unwrap_number(value: JsonValue) -> f64 { if let JsonValue::Number(n) = value { n } else { panic!("The passed value was not a number"); } }
More recently, we got let else, which lets us assert that it is one particular variant, or quit early.
fn unwrap_number(value: JsonValue) -> f64 { let JsonValue::Number(number) = value else { panic!("The passed value was not a number"); } number }
But importantly, there's no way to get the inner value without first checking for the variant.
In the Beginning: The Result
type
Rust doesn't have exceptions. The panic
macro, shown earlier, causes an immediate end to
the program (in most cases). For catchable errors, we use the Result
type, which is
included in Rust's core library.
pub enum Result<T, E> { Ok(T), Err(E), }
The Result
type has two generic types: T
, which is the type that was
expected to be returned; and E
, which is the type that will be returned instead if an
error occurred. For example, we could define a parse_number
function like this:
fn parse_to_f64(s: &str) -> Result<f64, ParseFloatError>
If the passed string is a number, then this function will return a 64-bit float. Otherwise, it will
return a ParseFloatError
, which is an error describing a failure to parse the number.
We can use the result of this function like so:
fn foo() { let number = match parse_to_f64("3.14159") { Ok(number) => number, Err(e) => panic!("{e}"), }; println!("{number}"); }
The Result
type has a convenient helper method to do this, called unwrap
.
If the result is Ok
, then the inner value will be returned. Otherwise, the program will
panic.
fn foo() { let number = parse_to_f64("3.14159").unwrap(); println!("{number}"); }
But typically, you'd want to just throw the error again.
fn foo() -> Result<(), ParseFloatError> { let number = match parse_to_f64("3.14159") { Ok(number) => number, Err(e) => return Err(e), }; println!("{number}"); }
Introduced Later: The ?
operator
As you might imagine, using that match expression over and over again to propogate errors will
quickly become tedious. The original solution was to use the try
macro to do it for you.
fn foo() -> Result<(), ParseFloatError> { let number = try!(parse_to_f64("3.14159")); println!("{number}"); }
But the 2018 edition of Rust introduced some syntactic sugar to make this even easier. It can be used
to propogate an error in any function which returns a similar Result
.
fn foo() -> Result<(), ParseFloatError> { let number = parse_to_f64("3.14159")?; println!("{number}"); }
It can even convert between errors, if possible.
enum JsonError { ParseNumber(ParseFloatError), Other(String), } impl From<ParseFloatError> for JsonError { fn from(value: ParseFloatError) -> Self { Self::ParseNumber(value) } } fn foo() -> Result<(), JsonError> { // automatically converts the ParseFloatError to a JsonError let number = parse_to_f64("3.14159")?; println!("{number}"); }
It also works on the Option
type, which is also included in Rust.
fn foo() -> Option<i32> { let array = [1, 2, 3, 4]; let x = array.get(4)?; // get the element at index 4, which doesn't exist in the array Some(x) }
And some of you may be surprised to learn that it also works on ControlFlow
and some
Poll
types.
This isn't stable yet, but there's also a proposal to allow the use of the ?
operator on
custom types. This would be done using a few new traits: Residual
,
FromResidual
, and Try
. A residual is a type that can be returned from using
a ?
. The FromResidual
trait is implemented on types that can be created
from residuals (for example, using ?
to get a
Result<Infallible, JsonError>
, then converting that to a
Result<(), JsonError>
). The Try
trait is implemented when a
FromResidual
type can also have the ?
operator applied to it. These traits
are already available in nightly Rust. As an exercise for the Dallas Rust User Group, we created
our own Result
type, and
used these traits to make the ?
operator work with it.
In Nightly: Try Blocks
Today, try
is a reserved keyword. So to use the try!
macro in newer
editions of Rust, you'll need to use the raw-literal syntax.
fn foo() -> Result<(), ParseFloatError> { let number = r#try!(parse_to_f64("3.14159")); println!("{number}"); }
What's the keyword being used for? It's for the try-block feature, which is currently only available
in nightly. The idea for this was made at roughly the same time as the ?
operator, to
catch anything that was propogated with it. However, only the ?
got merged.
let x: Result<_, _> = try { foo()?.bar()?.baz()? };
This way, you can catch an error without having to create a whole new function.
The Future: Catch blocks
From what I understand, the original idea was to not reserve try
as a keyword. Instead,
the keyword was going to be catch
. That idea was dropped, presumably because it would
break with the convention created by every other programming language. But this also leaves the door
open to introduce catch blocks later.
I haven't found any proposed RFCs for what catch blocks might look like. I think most people don't
want to think about this until try
is finished. But people have noted that we have the
option of adding catch later, and the rough syntax isn't too hard to figure out.
let x: Result<f64, _> = try { //... } catch error { panic!("Unexpected error: {:?}", error); }
We could pattern match on the expression too.
let x: f64 = try { //... } catch JsonError::ParseNumber(error) { panic!("Failed to parse float: {:?}", error); } catch JsonError::Other(error) { panic!("An unexpected error occurred: {:?}", error); }
We'd also have to allow try-catch on Option
.
let array = [1, 2, 3, 4]; let x = try { array.get(4)?; } catch { // note that there's no reason for a variable to be created here // this would probably desugar to `catch _` // that desugaring would allow this syntax to also be used with Result panic!("wait, what am i doing with my life?"); }
Creating this API would probably also mean we'd have to think about adding a Catch
trait, so that it could be allowed on custom types. Modeling this is difficult, but can be done.
pub trait Catch: Try { type Error; fn error(residual: Self::Residual) -> Self::Error; } impl<T, E> for Result<T, E> { type Error = E; fn error(residual: Self::Residual) -> Self::Error { let Err(error) = self; error } } impl<T> for Option<T> { type Error = (); fn error(residual: Self::Residual) -> Self::Error { () } }
In case you're wondering about the desugaring:
let x: f64 = match try { /* ... */ }.branch() { ControlFlow::Continue(output) => output, ControlFlow::Break(residual) => match Result::error(residual) { JsonError::ParseNumber(error) => panic!("Failed to parse float: {:?}", error), JsonError::Other(error) => panic!("An unexpected error occurred: {:?}", error), } }
Does that mean that finally
could be added in the future too? Perhaps. I've never been a
big fan of finally blocks. But we can do it. If you're familiar with the
scopeguard crate, then it shouldn't seem too difficult.
let x: f64 = try { //... } catch JsonError::ParseNumber(error) { panic!("Failed to parse float: {:?}", error); } catch JsonError::Other(error) { panic!("An unexpected error occurred: {:?}", error); } finally { println!("finally! we're done!"); } // desugars to... let x: f64 = { defer! { println!("finally! we're done!"); } match try { /* ... */ }.branch() { ControlFlow::Continue(output) => output, ControlFlow::Break(residual) => match Result::error(residual) { JsonError::ParseNumber(error) => panic!("Failed to parse float: {:?}", error), JsonError::Other(error) => panic!("An unexpected error occurred: {:?}", error), } } }
Yeet! We can go even further
Now that we have most of the syntax from languages that have exceptions, let's go even further.
We could consider adding the throw e
keyword as syntactic sugar for
return Err(e)
.
let x: f64 = try { throw JsonError::Other("oh no, an error!".to_string()); } catch JsonError::ParseNumber(error) { panic!("Failed to parse float: {:?}", error); } catch JsonError::Other(error) { panic!("An unexpected error occurred: {:?}", error); } finally { println!("finally! we're done!"); }
This isn't available in nightly, because throw
isn't yet a keyword in Rust. Worse, we
can't add it as a keyword until everybody agrees on what keyword to use. Some languages, such
Python, use raise
instead.
This problem is known as bikeshedding. People can't agree on what color to paint the bikeshed, so the bikeshed never gets built. Everybody agrees that having the bikeshed is important, regardless of what color it is. A common solution to this is to pick an option nobody would ever want. This lets everyone know that we're not actually going to use that color, so we can and will change it later. This happened.
#![feature(yeet_expr)] fn throw_error() -> Result<Infallible, JsonError> { do yeet JsonError::Other("an error has been yeeted"); }
Yep, yeet
is the chosen keyword. The do
needs to be there because
yeet
is not a reserved word, but do
is, despite it not being used for
anything.
The way this works under the hood, is do yeet e
desugars to Yeet(e)
.
Then a couple of FromResidual
implementations allow the Yeet
type to be
used for error propogation.
impl<T> FromResidual<Yeet<()>> for Option<T> { fn from_residual(_: Yeet<()>) -> Option<T> { None } } impl<T, E, F: From<E>> FromResidual<Yeet<E>> for Result<T, F> { fn from_residual(error: Yeet<E>) -> Result<T, F> { Err(error.0.into()) } }
Now that we've been using yeet
for so long, there are some people who want this to be
the new keyword. There was even a proposal to reserve yeet for Rust 2021. It was rejected.
Now in Reverse? What?
I had to share this because Rust does not have exceptions. The idea that we could introduce an exception-like syntax for a language which doesn't have them is amazing to me.
This got me thinking, could we introduce Result
to a language that doesn't have it?
Imagine the following C# code, where using try, but without a catch, returns a Result
.
// I used a wildcard for the error type because C# can throw one of multiple exceptions here Result<decimal, _> = try { yield demical.Parse("3.14159"); }
This would be easier in languages where block expressions exist. If a language were to pursue this,
I'd prefer they call the type Except
or Exceptional
instead of
Result
.
Conclusion
The main I reason I wrote this post was because, when I proposed a new scripting language in an earlier blog post, I wanted to talk about how try/catch would be implemented in it. But I figured that was way too off-topic for that post. So I wanted to discuss it here.
My scripting language would have unchecked exceptions, with the option to catch errors and put them
inside of an Exceptional
type.
function throws() { throw "oh no" } throws() // this crashes the script
Wrapping an expression in a try block yields an Except
type.
function throws() { fail with "oh no" } let x: Except<any, any> = try { throws() // this doesn't crash }
I think it's feasible to use a postfix !
as syntactic sugar for this
function throws() { fail with "oh no" } let x: Except<any, any> = throws()!
Then with catch
or .catch
, the error can be handled
function throws() { fail with "oh no" } let x: Except<any, any> = throws()!.catch error { print("an error occurred") exit() }
I like this idea. I'm glad I could share it with you.