f. Error Handling

How to handle errors returned from Tauri Rust commands, propagate meaningful messages to the React frontend, and display user-friendly feedback.

When a Tauri command fails in Rust, the error doesn't vanish—it travels back through the IPC bridge and lands in your JavaScript invoke call as a rejected promise. Handling that rejection properly is what keeps your app from silently breaking in the user’s hands. Without it, a failed file write, an invalid input, or a network timeout looks the same as nothing happening at all.

This section covers the full pipeline: crafting errors in Rust that are both machine‑readable and human‑friendly, catching them on the frontend, and presenting them to the user without technical jargon.

The Rust Side – Returning Errors from Commands

Every Tauri command that can fail must return a Result<T, E>. The framework serializes the Ok variant as the promise resolution and the Err variant as the rejection. That means E must implement both std::fmt::Display (to produce a fallback message) and serde::Serialize (to send structured data across the boundary).

Missing Trait Bounds:

If your custom error type does not implement Serialize, the compiler will refuse to compile the command. The error message from rustc will mention that the type doesn’t satisfy the Serialize bound required by the Tauri command macro—watch for it if you’re defining your own error enum.

Simple Error with a String

The quickest way to signal failure is to return Result<T, String>. Tauri will send that string as the rejection value, which you can access on the frontend as a plain text message.

Rust (src-tauri/src/lib.rs)

#[tauri::command]
fn divide_numbers(numerator: f64, denominator: f64) -> Result<f64, String> {
    if denominator == 0.0 {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(numerator / denominator)
    }
}

A String error is immediate and readable, but the frontend gets a raw, hard‑coded English phrase. That’s fine for quick prototypes, but it ties the UI to whatever string the backend produces, with no easy way to localize or branch on error type programmatically.

Custom Error Types

For anything beyond a prototype, you want an error enum that the frontend can inspect. The enum must derive Serialize, Deserialize, and Debug, and implement Display. The thiserror crate reduces the boilerplate considerably.

Rust (src-tauri/src/lib.rs)

use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug, Serialize)]
enum CommandError {
    #[error("Invalid input: {0}")]
    InvalidInput(String),
    #[error("Operation failed: {0}")]
    OperationFailed(String),
    #[error("Resource not found")]
    NotFound,
}
#[tauri::command]
fn update_settings(key: String, value: String) -> Result<(), CommandError> {
    if key.is_empty() {
        return Err(CommandError::InvalidInput("Setting key cannot be empty".into()));
    }
    // ... logic that might fail
    Ok(())
}

Good Practice:

Using an enum with #[error("...")] messages means each variant automatically gets a readable description. The frontend can check for a specific variant (e.g., code === "InvalidInput") and show a localized message or a different UI treatment.

How the error arrives in JavaScript: Tauri serializes the Err variant as a plain object. For the InvalidInput variant above, the rejection value will be:

{
  "InvalidInput": "Setting key cannot be empty"
}

The outer key is the variant name, and the inner value is the content. You can destructure it, or rely on the message property that Tauri also attaches—a stringified version of the error. Prefer the structured data for logic; fall back to the message for display when you haven't mapped it yet.

import { invoke } from "@tauri-apps/api/core";
try {
  await invoke("update_settings", { key: "", value: "off" });
} catch (error: any) {
  if (error.InvalidInput) {
    console.error("Validation error:", error.InvalidInput);
  }
}

Mapping Internal Errors Before Returning

A command often calls functions that return standard Rust errors (std::io::Error, serde_json::Error, etc.). Those errors don’t implement Serialize by default, so you must convert them into your custom type before the command returns. The ? operator works well if your custom error implements From<T> for the source error.

use std::fs;
#[derive(Error, Debug, Serialize)]
enum AppError {
    #[error("File operation failed: {0}")]
    FileError(String),
}
impl From<std::io::Error> for AppError {
    fn from(err: std::io::Error) -> Self {
        AppError::FileError(err.to_string())
    }
}
#[tauri::command]
fn read_file_contents(path: String) -> Result<String, AppError> {
    let contents = fs::read_to_string(&path)?; // io::Error auto-converts
    Ok(contents)
}

Don't Expose Raw System Paths:

An io::Error often includes the full file path in its message. If you blindly send that to the frontend, you might leak filesystem structure. Map the error to a user‑friendly message and log the raw error on the backend (via eprintln! or the log crate) for debugging.

The Frontend Side – Catching and Interpreting Errors

The invoke function returns a promise. When the Rust command returns Err, the promise rejects. You catch that rejection with a try/catch block or a .catch() handler. The caught value is not an Error object; it’s the serialized payload from Rust.

A common trap is assuming error will have the familiar error.message structure from JavaScript. It does, but that message is the stringified version of the entire error object, which is rarely the best text to show a user. Always inspect the structured fields first.

import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
function SettingsForm() {
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
  async function saveSetting(key: string, value: string) {
    setErrorMsg(null);
    try {
      await invoke("update_settings", { key, value });
    } catch (err: any) {
      // Prefer structured message
      if (err.InvalidInput) {
        setErrorMsg(`Invalid field: ${err.InvalidInput}`);
      } else if (err.OperationFailed) {
        setErrorMsg(`Could not save: ${err.OperationFailed}`);
      } else {
        // Fallback to serialized string
        setErrorMsg(err.message ?? "An unknown error occurred");
      }
    }
  }
  return (
    <div>
      {errorMsg && <div className="error-banner">{errorMsg}</div>}
      {/* form fields calling saveSetting */}
    </div>
  );
}

TypeScript Caveat:

Tauri’s type generation (via @tauri-apps/cli) can create TypeScript bindings for your commands, which includes the error shape. If you generate those bindings, invoke becomes type‑safe and you can match on exact error variants without any casts. For most tutorials, explicit any or manual interfaces are shown for clarity, but in a real project the generated types are a better choice.

Displaying User‑Friendly Messages

The raw error from Rust is rarely something you want to flash on screen. A message like "io::Error { kind: NotFound, ... }" means nothing to a non‑technical user. You need a mapping layer that converts error codes or variant names into localized, actionable text.

A pragmatic approach is to keep a dictionary of known error keys in the React component or a shared module:

const ERROR_MESSAGES: Record<string, string> = {
  InvalidInput: "Please check your input and try again.",
  OperationFailed: "Something went wrong while saving. Try again later.",
  NotFound: "The requested item could not be found.",
};
function mapError(err: any): string {
  for (const key of Object.keys(ERROR_MESSAGES)) {
    if (key in err) return ERROR_MESSAGES[key];
  }
  return "An unexpected error occurred. Please contact support.";
}

Then inside your catch block, call mapError(err) to get the display string. This separates presentation from the underlying error mechanics, making localization possible later.

Asynchronous Commands and Error Propagation

Tauri commands can be async. Error handling works identically: an async command that returns Result<T, E> will reject the promise on Err. The key difference is that if the async function itself panics or the future is dropped, the promise might never resolve or reject. To guard against that, ensure every code path explicitly returns a Result.

#[tauri::command]
async fn fetch_remote_data(url: String) -> Result<String, String> {
    let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
    let body = response.text().await.map_err(|e| e.to_string())?;
    Ok(body)
}

Here, map_err converts reqwest::Error into a String because reqwest::Error doesn’t implement Serialize. For production code, converting to a custom enum with context is preferable.

Unhandled Async Panics:

If a Rust async block panics (e.g., from an unwrap()), Tauri may report a generic "command not found" or the promise hangs forever. Audit async commands for any unwrap() or expect() calls and replace them with proper error handling.

Common Mistakes and Debugging

Missing Serialize on Error Type

As mentioned earlier, the most frequent compilation error is:

the trait `Serialize` is not implemented for `MyError`

The fix is to derive Serialize on the error type. If you’re wrapping a third‑party error that doesn’t implement Serialize, convert it into a string or a serializable struct before returning.

Swallowing Errors on the Frontend

A catch block that logs and does nothing else leaves the user staring at a frozen UI. Always update the component state or show a notification so the user knows something went wrong.

// ❌ bad — silent failure
try { await invoke("some_cmd"); } catch(e) { console.log(e); }
// ✅ good — the UI reacts
try { ... } catch(e) { setError(mapError(e)); }

Revealing Stack Traces or Internals

Tauri does not send the Rust panic backtrace to the frontend by default, but if you manually serialize a Debug representation of an error, you could leak internal paths. Stick to user‑friendly messages and log the raw error on the backend.

Assuming All Errors Are the Same Shape

If your backend uses multiple error types across commands, the frontend must handle each command’s rejection specifically. A single catch that prints err.message will work only if every command returns an error with a useful message property. For consistency, define a shared error enum across all your Tauri commands, or at least document the expected rejection shape for each.

Summary

Error handling in Tauri bridges a Rust idiom—Result<T, E>—with JavaScript’s promise model. The backend must produce errors that are serializable and meaningful. The frontend must catch those rejections, interpret the structured payload, and present actionable messages.

The single most impactful decision you can make is to use a custom error enum from the start rather than raw strings. It costs a few extra lines of Rust but gives the frontend the ability to branch on error type, localize messages, and maintain a consistent user experience without duplicating string comparisons.