From 5ec2d93446ae890e164dd8ad33602a09c0d8814e Mon Sep 17 00:00:00 2001 From: Mica White Date: Sat, 5 Sep 2026 21:29:35 -0400 Subject: First commit --- src/blog/try-catch-in-rust.kuht | 553 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100755 src/blog/try-catch-in-rust.kuht (limited to 'src/blog/try-catch-in-rust.kuht') diff --git a/src/blog/try-catch-in-rust.kuht b/src/blog/try-catch-in-rust.kuht new file mode 100755 index 0000000..63734cd --- /dev/null +++ b/src/blog/try-catch-in-rust.kuht @@ -0,0 +1,553 @@ + + + + How to Introduce try-catch to Rust + + + + + +
+ +

How to Introduce 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.

+ +
+ -- cgit v1.3.1