Tauri Commands
Rust commands with the invoke pattern, argument passing, return types, async commands, error handling, state management, and type safety between Rust and TypeScript in Tauri 2.0.
You are an expert in Tauri's command system for building type-safe communication between Rust backends and TypeScript frontends.
## Key Points
- **Giant monolithic commands**: Commands that do too many things. Split them into focused operations.
- **Returning raw Rust errors**: Rust's error types don't serialize well. Always map errors to serializable types.
- **Passing large data through IPC repeatedly**: If the frontend needs to work with large datasets, send them once and cache, or use streaming.
- **Ignoring async for I/O operations**: Blocking commands freeze the IPC thread. Use async for file I/O, network requests, and database queries.
- **Stringly-typed arguments**: Using `String` for everything instead of proper typed structs loses compile-time safety.
## Quick Example
```typescript
import { invoke } from "@tauri-apps/api/core";
const greeting = await invoke<string>("greet", { name: "Alice" });
```
```rust
#[tauri::command(rename_all = "snake_case")]
fn get_user_data(user_id: i64) -> UserData { /* ... */ }
// Frontend calls with snake_case:
// invoke("get_user_data", { user_id: 123 })
```skilldb get tauri-skills/Tauri CommandsFull skill: 432 linesTauri Commands — Rust-TypeScript IPC
You are an expert in Tauri's command system for building type-safe communication between Rust backends and TypeScript frontends.
Core Philosophy
Tauri commands are the bridge between your Rust backend and webview frontend. Every command is a Rust function annotated with #[tauri::command] that the frontend invokes by name. The system serializes arguments and return values through serde, so you get type safety on both sides when you define your types carefully.
Design commands as a clean API boundary. Each command should represent a discrete operation -- not a dump of internal implementation. Think of commands like REST endpoints: well-named, with clear input/output contracts, and proper error handling. The frontend should never need to know about Rust internals; it should only know the command name, its arguments, and its return type.
Keep commands thin. They should validate input, call into your application logic, and return results. Put the actual business logic in separate Rust modules so commands stay testable and the IPC surface stays clean.
Anti-Patterns
- Giant monolithic commands: Commands that do too many things. Split them into focused operations.
- Returning raw Rust errors: Rust's error types don't serialize well. Always map errors to serializable types.
- Passing large data through IPC repeatedly: If the frontend needs to work with large datasets, send them once and cache, or use streaming.
- Ignoring async for I/O operations: Blocking commands freeze the IPC thread. Use async for file I/O, network requests, and database queries.
- Stringly-typed arguments: Using
Stringfor everything instead of proper typed structs loses compile-time safety.
Basic Commands
Simple Command
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// Register in builder
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
import { invoke } from "@tauri-apps/api/core";
const greeting = await invoke<string>("greet", { name: "Alice" });
Command with Multiple Arguments
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct SearchResult {
items: Vec<String>,
total: usize,
}
#[tauri::command]
fn search(query: &str, page: usize, per_page: usize) -> SearchResult {
// Perform search
SearchResult {
items: vec![format!("Result for '{}'", query)],
total: 1,
}
}
interface SearchResult {
items: string[];
total: number;
}
const results = await invoke<SearchResult>("search", {
query: "tauri",
page: 1,
perPage: 20, // camelCase in TS maps to snake_case in Rust
});
Command Name Mapping
Rust snake_case function names map to camelCase in TypeScript by default. You can override this:
#[tauri::command(rename_all = "snake_case")]
fn get_user_data(user_id: i64) -> UserData { /* ... */ }
// Frontend calls with snake_case:
// invoke("get_user_data", { user_id: 123 })
Async Commands
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("Failed to read file: {}", e))
}
#[tauri::command]
async fn fetch_data(url: String) -> Result<serde_json::Value, String> {
let response = reqwest::get(&url)
.await
.map_err(|e| e.to_string())?;
response.json()
.await
.map_err(|e| e.to_string())
}
Error Handling
Custom Error Type
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("File not found: {0}")]
NotFound(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Unknown error: {0}")]
Unknown(String),
}
// Tauri requires errors to be serializable
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
// Now commands can use Result<T, AppError>
#[tauri::command]
async fn load_document(path: String) -> Result<Document, AppError> {
if !std::path::Path::new(&path).exists() {
return Err(AppError::NotFound(path));
}
let content = tokio::fs::read_to_string(&path).await?;
Ok(Document::parse(&content).map_err(|e| AppError::Unknown(e.to_string()))?)
}
Structured Error Response
#[derive(Serialize)]
struct ErrorResponse {
code: String,
message: String,
details: Option<serde_json::Value>,
}
impl From<AppError> for ErrorResponse {
fn from(err: AppError) -> Self {
match err {
AppError::NotFound(path) => ErrorResponse {
code: "NOT_FOUND".into(),
message: format!("File not found: {}", path),
details: None,
},
AppError::PermissionDenied(msg) => ErrorResponse {
code: "PERMISSION_DENIED".into(),
message: msg,
details: None,
},
// ...
_ => ErrorResponse {
code: "UNKNOWN".into(),
message: err.to_string(),
details: None,
},
}
}
}
Frontend Error Handling
try {
const doc = await invoke<Document>("load_document", { path: "/some/file" });
} catch (error) {
// error is the serialized error string or object
console.error("Command failed:", error);
}
State Management
Defining and Using State
use std::sync::Mutex;
use tauri::State;
struct AppState {
counter: Mutex<i32>,
db: Mutex<rusqlite::Connection>,
}
#[tauri::command]
fn increment(state: State<'_, AppState>) -> i32 {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
*counter
}
#[tauri::command]
fn get_count(state: State<'_, AppState>) -> i32 {
*state.counter.lock().unwrap()
}
fn run() {
let db = rusqlite::Connection::open("app.db").unwrap();
tauri::Builder::default()
.manage(AppState {
counter: Mutex::new(0),
db: Mutex::new(db),
})
.invoke_handler(tauri::generate_handler![increment, get_count])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Async-Friendly State with RwLock
use tokio::sync::RwLock;
struct AsyncState {
cache: RwLock<HashMap<String, String>>,
}
#[tauri::command]
async fn get_cached(key: String, state: State<'_, AsyncState>) -> Result<Option<String>, String> {
let cache = state.cache.read().await;
Ok(cache.get(&key).cloned())
}
#[tauri::command]
async fn set_cached(key: String, value: String, state: State<'_, AsyncState>) -> Result<(), String> {
let mut cache = state.cache.write().await;
cache.insert(key, value);
Ok(())
}
Accessing the Window and AppHandle
use tauri::{AppHandle, Manager, WebviewWindow};
#[tauri::command]
fn with_window(window: WebviewWindow) -> String {
let label = window.label().to_string();
format!("Called from window: {}", label)
}
#[tauri::command]
async fn with_app_handle(app: AppHandle) -> Result<(), String> {
// Access app-level resources
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
println!("Data dir: {:?}", data_dir);
// Emit events to all windows
app.emit("backend-event", "something happened")
.map_err(|e| e.to_string())?;
Ok(())
}
Type Safety Between Rust and TypeScript
Shared Types Pattern
// src-tauri/src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
pub id: i64,
pub name: String,
pub email: String,
pub role: UserRole,
pub created_at: String, // ISO 8601
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub enum UserRole {
Admin,
Editor,
Viewer,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateUserRequest {
pub name: String,
pub email: String,
pub role: UserRole,
}
// src/types.ts — mirror the Rust types
interface User {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
createdAt: string;
}
interface CreateUserRequest {
name: string;
email: string;
role: "admin" | "editor" | "viewer";
}
// Typed invoke wrapper
async function createUser(req: CreateUserRequest): Promise<User> {
return invoke<User>("create_user", { request: req });
}
Optional and Nullable Fields
#[tauri::command]
fn find_user(id: Option<i64>, name: Option<String>) -> Result<Option<User>, String> {
// Both arguments are optional from the frontend
// Return can also be null
Ok(None)
}
// Optional args can be omitted
const user = await invoke<User | null>("find_user", { id: 42 });
const user2 = await invoke<User | null>("find_user", { name: "Alice" });
Events (Bidirectional Communication)
Rust to Frontend
use tauri::Emitter;
#[tauri::command]
async fn start_processing(app: AppHandle) -> Result<(), String> {
tokio::spawn(async move {
for i in 0..100 {
app.emit("progress", i).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
app.emit("complete", "done").unwrap();
});
Ok(())
}
Frontend Listening
import { listen } from "@tauri-apps/api/event";
const unlisten = await listen<number>("progress", (event) => {
console.log(`Progress: ${event.payload}%`);
});
const unlistenComplete = await listen<string>("complete", (event) => {
console.log("Processing complete:", event.payload);
unlisten(); // Clean up
unlistenComplete();
});
await invoke("start_processing");
Command Organization
// src-tauri/src/commands/mod.rs
pub mod users;
pub mod files;
pub mod settings;
// src-tauri/src/commands/users.rs
#[tauri::command]
pub async fn list_users(state: State<'_, AppState>) -> Result<Vec<User>, AppError> { /* ... */ }
#[tauri::command]
pub async fn create_user(request: CreateUserRequest, state: State<'_, AppState>) -> Result<User, AppError> { /* ... */ }
// src-tauri/src/lib.rs
mod commands;
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
commands::users::list_users,
commands::users::create_user,
commands::files::read_file,
commands::files::write_file,
commands::settings::get_settings,
commands::settings::update_settings,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Install this skill directly: skilldb add tauri-skills
Related Skills
Tauri Distribution
Distributing Tauri applications including installers for MSI, DMG, AppImage, and deb, auto-update with the built-in updater, code signing for Windows and macOS, CI/CD builds, and cross-compilation.
Tauri Frontend
Frontend integration with Tauri 2.0 including React, Vue, Svelte, and Solid frameworks, Vite configuration, asset handling, window management, multiple windows, and webview communication.
Tauri Fundamentals
Tauri 2.0 architecture, Rust backend with webview frontend, project setup with Cargo and npm, development workflow, and build targets for Windows, macOS, Linux, iOS, and Android.
Tauri Mobile
Tauri 2.0 mobile development for iOS and Android, including platform-specific code, mobile plugins, testing on simulators and devices, and app store distribution.
Tauri Patterns
Common Tauri 2.0 patterns: system tray apps, menu bar apps, file handling, SQLite database integration, IPC communication patterns, background tasks, and single-instance enforcement.
Tauri Plugins
Tauri 2.0 plugin system including official plugins for filesystem, shell, dialog, notification, HTTP, clipboard, updater, and deep-link, plus community plugins and writing custom plugins.