Rust Error Handling in Tauri Apps | Custom Error Types with thiserror
Video: Rust Error Handling in Tauri Apps | Custom Error Types with thiserror by Taught by Celeste AI - AI Coding Coach
#[derive(thiserror::Error)]for ergonomic error types, plusserde::Serializeso they cross the IPC boundary into your frontend.
Tauri commands return Result<T, E>. The simplest E is String — convert any error to a message and ship it. That works for prototypes. Production apps need richer errors that the frontend can switch on, log, and translate into user-facing messages. The Rust ecosystem's standard tool for that is thiserror.
Install
# Cargo.toml
thiserror = "1"
serde = { version = "1", features = ["derive"] }
Defining an error type
use thiserror::Error;
use serde::Serialize;
#[derive(Debug, Error, Serialize)]
#[serde(tag = "kind", content = "message")]
pub enum AppError {
#[error("File not found: {0}")]
NotFound(String),
#[error("Permission denied for {0}")]
PermissionDenied(String),
#[error("Database error: {0}")]
Database(String),
#[error("Network error: {0}")]
Network(String),
#[error("Validation failed: {0}")]
Validation(String),
#[error("Unknown error: {0}")]
Unknown(String),
}
thiserror::Error derives std::error::Error automatically — Display, source, the lot.
Serialize is what makes it crossable to JS. The #[serde(tag = "kind", content = "message")] attribute makes the JSON shape predictable: { "kind": "NotFound", "message": "..." }.
Using in a command
#[tauri::command]
fn read_note(path: String) -> Result<String, AppError> {
if !std::path::Path::new(&path).exists() {
return Err(AppError::NotFound(path));
}
std::fs::read_to_string(&path)
.map_err(|e| AppError::Unknown(e.to_string()))
}
The command returns Result<String, AppError>. On success, the JS-side promise resolves to the string. On error, it rejects with a structured object.
Frontend handling
type AppError =
| { kind: "NotFound"; message: string }
| { kind: "PermissionDenied"; message: string }
| { kind: "Database"; message: string }
| { kind: "Network"; message: string }
| { kind: "Validation"; message: string }
| { kind: "Unknown"; message: string };
try {
const text = await invoke<string>("read_note", { path });
setContent(text);
} catch (err) {
const e = err as AppError;
switch (e.kind) {
case "NotFound":
setError("That file doesn't exist.");
break;
case "PermissionDenied":
setError("You don't have permission to read that file.");
break;
default:
setError(`Something went wrong: ${e.message}`);
}
}
The frontend gets to switch on the kind. User-visible messages can be tailored per error variant. Telemetry can group by kind. Localisation can translate per kind.
Converting from std errors
For each std error type your code touches, implement From so ? works:
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
match e.kind() {
std::io::ErrorKind::NotFound => AppError::NotFound(e.to_string()),
std::io::ErrorKind::PermissionDenied => AppError::PermissionDenied(e.to_string()),
_ => AppError::Unknown(e.to_string()),
}
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Database(e.to_string())
}
}
Now your commands get cleaner:
#[tauri::command]
fn read_note(path: String) -> Result<String, AppError> {
Ok(std::fs::read_to_string(&path)?)
}
The ? operator converts std::io::Error to AppError automatically via the From impl.
Avoiding leaks
Don't put internal paths, stack traces, or DB query strings into messages that reach the frontend. They're a security risk and aren't useful to the user.
// BAD: leaks the SQL
Err(AppError::Database(format!("query failed: {}", actual_sql)))
// GOOD: log the SQL, surface a user-safe message
log::error!("SQL failed: {}", actual_sql);
Err(AppError::Database("Couldn't read your contacts. Try again.".into()))
The user sees the friendly message; the developer sees the SQL in logs.
Anyhow for application code, thiserror for library code
A common Rust pattern:
- Library code (functions you reuse): typed errors with
thiserror. - Application glue (the binary's main, top-level handlers):
anyhow::Result<T>— opaque errors with chained context.
For Tauri commands, you usually want typed errors so the frontend can handle them. Use thiserror here. Inside helper functions that aren't commands, anyhow is fine.
Logging errors
Always log errors before returning them. Two reasons: production debugging (the user reports a bug; you check logs and see the actual error chain), and surfacing context that's too detailed for users.
#[tauri::command]
fn read_note(path: String) -> Result<String, AppError> {
match std::fs::read_to_string(&path) {
Ok(text) => Ok(text),
Err(e) => {
log::error!("read_note failed for {}: {}", path, e);
Err(AppError::from(e))
}
}
}
Or, more idiomatically, use tracing and let #[instrument] log automatically.
Validation errors
For form validation, a richer Validation variant is useful:
#[derive(Debug, Error, Serialize)]
pub enum AppError {
// ...
#[error("Validation failed")]
Validation { fields: HashMap<String, String> },
}
fn validate_contact(c: &Contact) -> Result<(), AppError> {
let mut errors = HashMap::new();
if c.name.is_empty() {
errors.insert("name".into(), "Required".into());
}
if !c.email.contains('@') {
errors.insert("email".into(), "Invalid format".into());
}
if !errors.is_empty() {
return Err(AppError::Validation { fields: errors });
}
Ok(())
}
The frontend maps fields["name"] to the name field's error and shows it inline.
Common mistakes
Returning raw Box<dyn Error>. Not Serializeable; can't cross the IPC boundary. Use a typed enum.
Forgetting #[serde(tag = "kind", content = "message")]. The default serialisation of an enum is { "NotFound": "message text" } — externally tagged. The kind/content form ({ "kind": "NotFound", "message": "..." }) is much easier for the frontend to switch on.
One catch-all Unknown variant for everything. Defeats the purpose of typed errors. Add specific variants for cases the frontend should handle differently.
Logging sensitive details to the frontend. Frontend errors are visible in dev tools and may end up in error reports. Keep them safe.
Different error type per command. Centralise on one AppError per app. If a command genuinely needs a niche error type, name it specifically.
What's next
Next lesson: async commands with progress events. When commands take a long time, the frontend wants progress updates — not just the final result.
Recap
thiserror::Error for derives, plus serde::Serialize for IPC. #[serde(tag = "kind", content = "message")] for predictable JSON. From impls so ? works inside commands. Log errors before returning. Use a single AppError enum per app. Avoid leaking internal details to the frontend.
Next: async commands.