Introducing try-catch to Rust

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.