← Back to Blog
TypeScript2026-05-12Β·78 min read

Rust Error Handling in Tauri Commands β€” The Pattern That Actually Works

By hiyoyo

Structured Error Propagation in Tauri: From Strings to Typed Enums

Current Situation Analysis

Tauri applications rely on an Inter-Process Communication (IPC) bridge between the Rust backend and the JavaScript frontend. This bridge imposes strict constraints on data serialization. When a command fails, the error must cross this boundary, be serialized, and be deserialized on the frontend.

The industry pain point is the prevalence of ad-hoc error handling at this boundary. Developers often default to returning String errors or panicking, treating the IPC channel as a simple text pipe. This approach creates a leaky abstraction where the frontend receives unstructured data that cannot be programmatically analyzed.

This problem is frequently overlooked because Result<T, String> compiles successfully and satisfies the serde::Serialize requirement. For prototypes, returning a string message works. However, as applications scale, this pattern introduces critical failures:

  1. Frontend Blindness: The UI receives a string. It cannot distinguish between a recoverable error (e.g., "Device disconnected") and a fatal error (e.g., "Database corrupted") without fragile string parsing.
  2. Security Risks: Raw error messages often contain internal paths, stack traces, or credential fragments. Returning these directly to the frontend exposes implementation details.
  3. Observability Gaps: Without structured error types, production logs become unsearchable noise. You cannot query logs for "All permission failures" or "All database timeouts" when every error is a unique string.
  4. Refactoring Debt: Changing an error message in Rust breaks frontend logic if the frontend matches on string content. There is no contract between backend and frontend.

Evidence from shipping multiple production Tauri applications indicates that retrofitting error handling is significantly more costly than implementing a structured pattern from the initial commit. The cost of technical debt accumulates rapidly as the number of commands and error variants grows.

WOW Moment: Key Findings

The shift from ad-hoc strings to typed domain errors transforms error handling from a display problem into a control flow problem. The following comparison highlights the operational differences between the naive approach and the structured pattern.

Approach Frontend Recovery Log Queryability Refactoring Safety Security Posture Implementation Cost
Ad-hoc Strings None. UI can only display text. Low. Requires regex or full-text search. High risk. String changes break UI. Poor. Leaks internal state by default. Low initial, high long-term.
Typed Domain Enums High. UI can trigger specific recovery flows. High. Structured fields enable precise filtering. Safe. Contract is defined by enum variants. Strong. Sensitive data can be sanitized at the boundary. Moderate initial, near-zero long-term.

Why this matters: Typed errors enable the frontend to implement intelligent recovery strategies. Instead of showing a generic "Something went wrong" dialog, the application can automatically retry a device connection, prompt the user to grant permissions, or switch to offline mode. This directly impacts user retention and support ticket volume.

Core Solution

The robust pattern for Tauri error handling relies on a single, app-wide error enum that implements serde::Serialize. This enum acts as the canonical contract for all command failures.

Architecture Decisions

  1. Tagged Enum Serialization: Use serde's tag and content attributes. This produces a JSON structure with a discriminator field, allowing the frontend to switch on error types without parsing messages.
  2. Boundary Logging: Log errors at the command invocation layer, not within business logic. This ensures every failure is recorded exactly once with a consistent format, separating concerns between domain logic and observability.
  3. From Implementations: Implement From traits for all dependency errors. This enables the ? operator to work seamlessly, converting third-party errors into the app-wide type automatically.
  4. Sanitization: The error enum should store only safe, user-facing messages. Internal details should be logged on the backend but stripped before serialization.

Implementation

1. Define the App-Wide Error Type

Create a dedicated module for errors. Use thiserror to reduce boilerplate and derive Serialize with a tagged structure.

use serde::Serialize;
use thiserror::Error;

/// Central error type for all Tauri commands.
/// Uses a tagged enum structure for safe IPC serialization.
#[derive(Debug, Error, Serialize)]
#[serde(tag = "category", content = "message")]
pub enum WorkspaceError {
    #[error("File system operation failed: {0}")]
    FileSystem(String),

    #[error("Device communication error: {0}")]
    Device(String),

    #[error("Data persistence error: {0}")]
    Persistence(String),

    #[error("Access control violation: {0}")]
    AccessDenied(String),

    #[error("Internal configuration error: {0}")]
    Configuration(String),
}

// Implement From for standard library errors
impl From<std::io::Error> for WorkspaceError {
    fn from(err: std::io::Error) -> Self {
        // Sanitize: Do not leak file paths or internal details
        let safe_msg = match err.kind() {
            std::io::ErrorKind::PermissionDenied => "Insufficient permissions".to_string(),
            std::io::ErrorKind::NotFound => "Target resource not found".to_string(),
            _ => "File system operation failed".to_string(),
        };
        WorkspaceError::FileSystem(safe_msg)
    }
}

// Example: Implement From for a database error
impl From<rusqlite::Error> for WorkspaceError {
    fn from(err: rusqlite::Error) -> Self {
        // Log the raw error for debugging, return safe message
        tracing::error!("Database error occurred: {:?}", err);
        WorkspaceError::Persistence("Data operation failed".to_string())
    }
}

2. Command Signature and Boundary Logging

Commands should return Result<T, WorkspaceError>. Use a wrapper or macro to handle logging at the boundary.

use tauri::command;
use tracing;

#[command]
async fn sync_device_data(
    device_id: String,
) -> Result<SyncResult, WorkspaceError> {
    // Business logic returns Result with specific errors
    // The ? operator converts them to WorkspaceError via From impls
    let result = perform_sync(&device_id).await?;

    Ok(result)
}

// Helper to wrap command execution with logging
// In production, this can be abstracted into a macro or middleware
async fn execute_with_logging<F, Fut, T>(
    command_name: &str,
    f: F,
) -> Result<T, WorkspaceError>
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = Result<T, WorkspaceError>>,
{
    match f().await {
        Ok(val) => Ok(val),
        Err(e) => {
            // Log at the boundary with structured data
            tracing::error!(
                command = command_name,
                error_category = ?e,
                "Command execution failed"
            );
            Err(e)
        }
    }
}

3. Frontend Type Safety

Define TypeScript interfaces that mirror the Rust enum structure. This ensures the frontend has compile-time guarantees about error shapes.

// types/errors.ts

export type ErrorCategory = 
  | 'FileSystem' 
  | 'Device' 
  | 'Persistence' 
  | 'AccessDenied' 
  | 'Configuration';

export interface WorkspaceError {
  category: ErrorCategory;
  message: string;
}

// Type guard for runtime checks
export function isWorkspaceError(error: unknown): error is WorkspaceError {
  return (
    typeof error === 'object' &&
    error !== null &&
    'category' in error &&
    'message' in error
  );
}

4. Frontend Error Handling

Use the typed error to drive UI logic.

import { invoke } from '@tauri-apps/api/core';
import { isWorkspaceError } from './types/errors';

async function handleSync() {
  try {
    await invoke('sync_device_data', { deviceId: 'dev-123' });
  } catch (error) {
    if (isWorkspaceError(error)) {
      switch (error.category) {
        case 'AccessDenied':
          showPermissionPrompt(error.message);
          break;
        case 'Device':
          showDeviceTroubleshooting(error.message);
          break;
        case 'Persistence':
          showDataRecoveryDialog(error.message);
          break;
        default:
          showGenericError(error.message);
      }
    } else {
      // Fallback for unexpected serialization errors
      console.error('Unknown error structure:', error);
      showGenericError('An unexpected error occurred.');
    }
  }
}

Pitfall Guide

1. Leaking Sensitive Internal State

  • Explanation: Converting errors directly to strings often includes file paths, database queries, or stack traces. Since errors are serialized to the frontend, this data is visible to the user and potentially malicious scripts.
  • Fix: Always sanitize error messages in From implementations. Store only user-safe descriptions in the enum variants. Log the raw error internally using tracing before converting.

2. Missing From Implementations

  • Explanation: If a dependency error lacks a From implementation, the ? operator fails to compile. Developers may resort to .map_err(|e| e.to_string()), reintroducing string errors.
  • Fix: Audit all dependencies. Implement From for every error type that can bubble up to a command. If a dependency has too many variants, group them into a single app variant with a generic message.

3. Frontend Type Drift

  • Explanation: The Rust enum and TypeScript interface can diverge over time. A new variant added in Rust may not exist in TypeScript, causing runtime crashes or unhandled cases.
  • Fix: Use code generation tools like ts-rs to automatically generate TypeScript types from Rust structs. Alternatively, enforce strict code review checks that require TS updates alongside Rust enum changes.

4. Logging in Business Logic

  • Explanation: Scattering log::error! calls throughout business functions leads to duplicate logs and inconsistent formatting. It also couples domain logic to the logging framework.
  • Fix: Log only at the command boundary. Business logic should return errors; the command layer decides how to log and return them. This keeps business logic pure and testable.

5. Variant Explosion

  • Explanation: Creating a separate enum variant for every possible error leads to a bloated enum that is hard to maintain. The frontend may not need to distinguish between "File not found" and "File locked".
  • Fix: Group errors by recovery action. If two errors require the same UI response, they should share a variant. Use the message field for specific details, but keep category focused on frontend behavior.

6. Ignoring Send and Sync Bounds

  • Explanation: Tauri commands often run asynchronously. Error types must be Send and Sync to be returned from async contexts. Some custom error types may violate these bounds.
  • Fix: Ensure the error enum and all contained data are Send + Sync. String is Send + Sync, but custom types wrapping non-thread-safe resources will fail. Use thiserror which handles these bounds correctly by default.

7. Over-Reliance on String Matching in Frontend

  • Explanation: Even with typed errors, developers might match on error.message content instead of error.category. This reintroduces fragility.
  • Fix: Enforce a coding standard where frontend logic switches only on category. The message field should be treated as opaque display text.

Production Bundle

Action Checklist

  • Define WorkspaceError enum with #[derive(Serialize)] and #[serde(tag = "category")].
  • Implement From traits for all dependency errors used in commands.
  • Sanitize error messages in From impls to remove sensitive data.
  • Update all Tauri command signatures to return Result<T, WorkspaceError>.
  • Add boundary logging using tracing at the command invocation layer.
  • Generate or manually define TypeScript interfaces matching the Rust enum.
  • Refactor frontend error handling to switch on category instead of parsing strings.
  • Audit production logs to verify structured error data is being captured.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Prototype / MVP Result<T, String> Speed of development is priority. Error handling is minimal. Low initial cost. High risk if product succeeds.
Production App Typed WorkspaceError Enables recovery flows, security, and observability. Moderate initial cost. Low maintenance cost.
Large Team Typed Error + ts-rs Prevents type drift between backend and frontend developers. Higher tooling setup. Prevents integration bugs.
Security-Critical App Typed Error + Strict Sanitization Prevents information leakage. Ensures auditability. High implementation effort. Essential for compliance.

Configuration Template

src/errors.rs

use serde::Serialize;
use thiserror::Error;
use tracing;

#[derive(Debug, Error, Serialize)]
#[serde(tag = "category", content = "message")]
pub enum WorkspaceError {
    #[error("File system error: {0}")]
    FileSystem(String),
    #[error("Device error: {0}")]
    Device(String),
    #[error("Database error: {0}")]
    Database(String),
    #[error("Permission error: {0}")]
    Permission(String),
}

impl From<std::io::Error> for WorkspaceError {
    fn from(e: std::io::Error) -> Self {
        tracing::warn!("IO error: {:?}", e);
        WorkspaceError::FileSystem("File operation failed".into())
    }
}

impl From<rusqlite::Error> for WorkspaceError {
    fn from(e: rusqlite::Error) -> Self {
        tracing::error!("DB error: {:?}", e);
        WorkspaceError::Database("Data operation failed".into())
    }
}

src/main.rs (Command Example)

use tauri::command;
use crate::errors::WorkspaceError;

#[command]
async fn read_config() -> Result<String, WorkspaceError> {
    let content = std::fs::read_to_string("config.json")?;
    Ok(content)
}

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

mod errors;
mod commands;

use errors::WorkspaceError;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            commands::read_config
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Quick Start Guide

  1. Add Dependencies: Include thiserror and tracing in Cargo.toml.
  2. Create Error Module: Add errors.rs with the WorkspaceError enum and From implementations.
  3. Update Commands: Change command return types to Result<T, WorkspaceError> and use ? for error propagation.
  4. Define Frontend Types: Create WorkspaceError interface in TypeScript and update error handling logic to use category.
  5. Verify: Run the app, trigger errors, and inspect the console to confirm structured JSON errors are received and logged correctly.