Handling Errors in Tauri

This post will give an overview of handling errors in Rust and how to work with them inside your Tauri commands.

Handling errors in Rust

In Rust, errors can be either recoverable (Result<T, E>) or unrecoverable (panics). For a deeper dive, refer to The Rust Book.

Unrecoverable errors, or panics, cause the program or thread to crash. While crashing is usually undesirable, it can be the best option in some scenarios. For example, if your app requires a local SQLite database and the setup or connection fails, panicking may be appropriate.

First, let's look at crashing when we're given an error.

expect() and unwrap()

use std::fs::File;
 
fn open_file() -> Result<File, std::io::Error> {
File::open("/some/path")
}
 
// panic! unwrap expect. Simple and effective! and crashes your program
fn using_expect_and_unwrap() {
let file = open_file().expect("this will crash the program");
// same thing without message
open_file().unwrap();
}

This example will crash if the path to the file doesn't exist. expect() will crash with a message and a stack trace, and unwrap() will do the same but without a message. This is useful for something critical to the rest of the application, like creating a SQLite connection.

Match statements

// match to gracefully handle the error, or carry on.
fn basic_handling_with_match() {
match open_file() {
Ok(file) => {
println!("carry on");
}
Err(e) => {
println!("handling the error: {e}");
}
}
}

The main way to handle errors in Rust is to use a match statement. It allows us to carry on if all is well or attempt recovery. For example, we could attempt to create the file if it didn't exist.

Bubbling errors up

// if you don't want to handle the error yet use the `?` to bubble up.
fn try_operator() -> Result<(), std::io::Error> {
let file = open_file()?;
 
// do other things
 
Ok(())
}

Another way to handle errors is by letting the caller handle them. Here we use the try operator (?). While calling open_file(), this will return the error to the caller of our function. However, we can only use the ? with functions that match our error type of std::io::Error. If it doesn't match, we will get a compiler error. This is where the anyhow crate comes in handy.

Anyhow

When attempting to bubble up multiple possible error types, we can use anyhow. All we need to do is return anyhow::Result<T> instead of Result<T, E>.

To install anyhow, cd into src-tauri and run:

cargo add anyhow
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufReader, Read};
 
type Json = HashMap<String, serde_json::Value>;
 
pub fn using_anyhow() -> anyhow::Result<Json> {
let json_file = File::open("/some/path/file.json")?;
let reader = BufReader::new(json_file);
 
let data = serde_json::from_reader(reader)?;
// because of anyhow we can use ? for both.
 
Ok(data)
}

This approach bubbles up errors from both File::open() and serde_json::from_reader(), simplifying error handling. However, Tauri commands don’t support returning anyhow::Result.

Errors in Tauri commands

Tauri commands connect the frontend to Rust, so it’s important to relay errors back to the frontend.

Panicking inside a command should always be avoided. If you panic inside a synchronous command, it will crash the app. An asynchronous command won’t crash on panic, but the JavaScript Promise will never resolve.

To provide the frontend with errors, Tauri commands must return a Result. However, since the return type must implement serde::Serialize, most errors don’t work directly. A simple workaround is mapping errors into strings.

use std::{fs::File, io::Read};
#[tauri::command]
pub fn error_as_string() -> Result<String, String> {
let mut file = File::open("some/path/file.txt").map_err(|e| e.to_string())?;
let mut buff = String::new();
 
file.read_to_string(&mut buff).map_err(|e| e.to_string())?;
 
Ok(buff)
}

Now we can call this command on the frontend and handle an error if one happens. Here's a basic JavaScript example.

async function myFunction() {
try {
await invoke("error_as_string");
} catch (e) {
console.log("handling error: ", e);
}
}

This works, but requires us to repeat .map_err(|e| e.to_string())? in multiple places. A better way is to return our own errors that implement serde::Serialize.

Using thiserror

The thiserror crate simplifies error handling by providing macros to define custom error types.

First, add thiserror to our dependencies.

cargo add thiserror

Then we can write our custom error and implement serde::Serialize for it.

use thiserror::Error;
 
#[derive(Debug, Error)]
pub enum MyCustomError {
#[error(transparent)]
File(#[from] io::Error),
 
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
}
 
 
impl serde::Serialize for MyCustomError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}

Here's a quick breakdown:

  • Derive thiserror::Error.
  • Use the #[error()] macro to customize error messages or pass them through with transparent.
  • Add the #[from] macro to automatically convert errors when using ?.
  • Implement serde::Serialize to convert errors into strings for the frontend.

We can now use the ? operator in commands to pass errors to the frontend.

use std::{fs::File, io};
 
#[tauri::command]
pub fn using_thiserror() -> Result<(), MyCustomError> {
File::open("some/path/file.txt")?;
 
Ok(())
}

Conclusion

Handling errors well in Tauri will make maintaining a project much easier and improve the experience for users with better messages. I recommend trying out all of the methods: use thiserror to return custom messages from your commands, attempt to recover from errors using match statements if possible, and panic only when necessary.