Tauri 2.0 Commands Creating Rust Backend Functions

Tauri commands are Rust functions exposed to the frontend through IPC enabling your JavaScript code to execute backend logic, access system resources, and perform computations leveraging Rust's performance and safety while maintaining security boundaries—mastering command creation patterns is essential for building sophisticated desktop applications with clean architecture separating presentation from business logic. Commands marked with the #[tauri::command] attribute automatically handle serialization, parameter extraction, and error propagation providing type-safe interfaces between frontend and backend with support for synchronous operations, async/await for I/O-bound tasks, state management for sharing data, and custom error types for meaningful error messages. This comprehensive guide covers understanding command fundamentals and the attribute macro system, creating basic synchronous commands with parameters and return values, implementing async commands for file I/O, network requests, and database operations, handling errors with Result types and custom error enums, working with complex data structures including nested types and collections, accessing application state for sharing data across commands, using window and app handles for system integration, implementing command middleware and validation, testing commands with unit and integration tests, and organizing commands in modules for large applications. Mastering these patterns enables building robust backends performing heavy computation in Rust, integrating with native libraries and system APIs, maintaining type safety across the IPC boundary, handling errors gracefully with recovery strategies, and creating reusable command logic. Before diving in, review IPC communication fundamentals and understand project structure.
Command Fundamentals
Commands are regular Rust functions annotated with #[tauri::command] that become callable from JavaScript through the invoke API with automatic serialization handling type conversion between Rust and JavaScript seamlessly.
// src-tauri/src/main.rs
// Simple command with no parameters
#[tauri::command]
fn hello_world() -> String {
String::from("Hello from Rust!")
}
// Command with single parameter
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
// Command with multiple parameters
#[tauri::command]
fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
// Command with different parameter types
#[tauri::command]
fn user_info(name: String, age: u32, is_active: bool) -> String {
format!(
"User: {} (Age: {}), Active: {}",
name, age, is_active
)
}
// Command returning nothing (Unit type)
#[tauri::command]
fn log_message(message: String) {
println!("[LOG] {}", message);
}
// Command with optional parameters (using Option)
#[tauri::command]
fn create_user(username: String, email: Option<String>) -> String {
match email {
Some(e) => format!("User {} with email {}", username, e),
None => format!("User {} without email", username),
}
}
// Command with default values (implemented in Rust)
#[tauri::command]
fn configure_app(theme: String, font_size: Option<u32>) -> String {
let size = font_size.unwrap_or(14);
format!("Theme: {}, Font size: {}", theme, size)
}
// Register commands in main function
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
hello_world,
greet,
add_numbers,
user_info,
log_message,
create_user,
configure_app,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}// Frontend: Calling commands from JavaScript/TypeScript
import { invoke } from "@tauri-apps/api/core";
// Call simple command
const message = await invoke<string>("hello_world");
console.log(message); // "Hello from Rust!"
// Call with parameter
const greeting = await invoke<string>("greet", { name: "Alice" });
console.log(greeting); // "Hello, Alice!"
// Call with multiple parameters
const sum = await invoke<number>("add_numbers", { a: 10, b: 5 });
console.log(sum); // 15
// Call with mixed types
const info = await invoke<string>("user_info", {
name: "Bob",
age: 30,
is_active: true
});
// Call with no return value
await invoke("log_message", { message: "Application started" });
// Call with optional parameter
const user1 = await invoke<string>("create_user", {
username: "john",
email: "[email protected]"
});
const user2 = await invoke<string>("create_user", {
username: "jane"
// email is optional, so we can omit it
});
// Call with default values
const config1 = await invoke<string>("configure_app", {
theme: "dark",
font_size: 16
});
const config2 = await invoke<string>("configure_app", {
theme: "light"
// font_size will default to 14 in Rust
});Async Commands for I/O Operations
Async commands enable non-blocking operations like file I/O, network requests, and database queries using Rust's async/await syntax powered by tokio runtime preventing UI freezes during long-running tasks.
// src-tauri/Cargo.toml - Add dependencies
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// reqwest = { version = "0.11", features = ["json"] }
// serde = { version = "1.0", features = ["derive"] }
// serde_json = "1.0"
// src-tauri/src/main.rs
use tokio::time::{sleep, Duration};
use serde::{Deserialize, Serialize};
// Simple async command
#[tauri::command]
async fn async_hello() -> String {
sleep(Duration::from_secs(1)).await;
String::from("Hello after 1 second!")
}
// Async file reading
use tokio::fs;
#[tauri::command]
async fn read_file_async(path: String) -> Result<String, String> {
fs::read_to_string(&path)
.await
.map_err(|e| format!("Failed to read file: {}", e))
}
// Async file writing
#[tauri::command]
async fn write_file_async(path: String, content: String) -> Result<(), String> {
fs::write(&path, content)
.await
.map_err(|e| format!("Failed to write file: {}", e))
}
// HTTP request
#[derive(Serialize, Deserialize)]
struct ApiResponse {
id: u32,
title: String,
body: String,
}
#[tauri::command]
async fn fetch_data(url: String) -> Result<ApiResponse, String> {
let response = reqwest::get(&url)
.await
.map_err(|e| format!("Request failed: {}", e))?;
let data = response
.json::<ApiResponse>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(data)
}
// POST request with body
#[derive(Serialize, Deserialize)]
struct CreatePostRequest {
title: String,
body: String,
user_id: u32,
}
#[tauri::command]
async fn create_post(url: String, post: CreatePostRequest) -> Result<ApiResponse, String> {
let client = reqwest::Client::new();
let response = client
.post(&url)
.json(&post)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let data = response
.json::<ApiResponse>()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(data)
}
// Multiple concurrent operations
#[tauri::command]
async fn fetch_multiple(urls: Vec<String>) -> Vec<Result<String, String>> {
let mut tasks = vec![];
for url in urls {
let task = tokio::spawn(async move {
reqwest::get(&url)
.await
.and_then(|r| r.text())
.await
.map_err(|e| e.to_string())
});
tasks.push(task);
}
let mut results = vec![];
for task in tasks {
match task.await {
Ok(result) => results.push(result),
Err(e) => results.push(Err(e.to_string())),
}
}
results
}
// Async command with timeout
use tokio::time::timeout;
#[tauri::command]
async fn fetch_with_timeout(url: String, timeout_secs: u64) -> Result<String, String> {
let fetch_future = async {
reqwest::get(&url)
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
};
timeout(Duration::from_secs(timeout_secs), fetch_future)
.await
.map_err(|_| String::from("Request timed out"))?
}Error Handling and Result Types
Proper error handling using Rust's Result type enables commands to fail gracefully communicating errors to the frontend with meaningful messages allowing users to understand and recover from failures maintaining application stability.
// Basic Result return
#[tauri::command]
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
return Err(String::from("Cannot divide by zero"));
}
Ok(a / b)
}
// Custom error type using thiserror
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("Database error: {0}")]
Database(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("Permission denied")]
PermissionDenied,
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Network error: {0}")]
Network(String),
}
// Implement Serialize for custom error
use serde::Serialize;
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())
}
}
// Use custom error in commands
#[tauri::command]
fn validate_email(email: String) -> Result<String, AppError> {
if email.is_empty() {
return Err(AppError::InvalidInput("Email cannot be empty".to_string()));
}
if !email.contains('@') {
return Err(AppError::InvalidInput("Invalid email format".to_string()));
}
Ok(format!("Valid email: {}", email))
}
#[tauri::command]
async fn read_config_file(path: String) -> Result<String, AppError> {
if !std::path::Path::new(&path).exists() {
return Err(AppError::FileNotFound(path));
}
tokio::fs::read_to_string(&path)
.await
.map_err(|e| AppError::Database(e.to_string()))
}
// Structured error response
#[derive(Serialize)]
struct ErrorResponse {
code: String,
message: String,
details: Option<String>,
}
#[tauri::command]
fn risky_operation(value: i32) -> Result<i32, ErrorResponse> {
if value < 0 {
return Err(ErrorResponse {
code: "NEGATIVE_VALUE".to_string(),
message: "Value must be positive".to_string(),
details: Some(format!("Received: {}", value)),
});
}
if value > 1000 {
return Err(ErrorResponse {
code: "VALUE_TOO_LARGE".to_string(),
message: "Value exceeds maximum".to_string(),
details: Some("Maximum allowed value is 1000".to_string()),
});
}
Ok(value * 2)
}
// Converting from other error types
use std::io;
#[tauri::command]
async fn process_file(path: String) -> Result<String, String> {
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("IO Error: {}", e))?;
let processed = content.to_uppercase();
tokio::fs::write(&path, &processed)
.await
.map_err(|e| format!("Write Error: {}", e))?;
Ok(processed)
}
// Using ? operator for error propagation
#[tauri::command]
async fn complex_operation(input: String) -> Result<String, AppError> {
// Validate input
if input.is_empty() {
return Err(AppError::InvalidInput("Input required".to_string()));
}
// Read from file - propagate error with ?
let data = tokio::fs::read_to_string(&input)
.await
.map_err(|e| AppError::FileNotFound(e.to_string()))?;
// Process data
let processed = data.to_uppercase();
// Write back
tokio::fs::write(&input, &processed)
.await
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(processed)
}// Frontend: Handling command errors
import { invoke } from "@tauri-apps/api/core";
// Basic error handling
try {
const result = await invoke<number>("divide", { a: 10, b: 0 });
} catch (error) {
console.error("Division failed:", error);
// error is the string returned from Rust
}
// Structured error handling
interface ErrorResponse {
code: string;
message: string;
details?: string;
}
async function performRiskyOperation(value: number) {
try {
return await invoke<number>("risky_operation", { value });
} catch (error) {
// Try to parse structured error
if (typeof error === "string") {
try {
const err: ErrorResponse = JSON.parse(error);
console.error(`[${err.code}] ${err.message}`);
if (err.details) {
console.error("Details:", err.details);
}
// Handle specific error codes
switch (err.code) {
case "NEGATIVE_VALUE":
alert("Please enter a positive number");
break;
case "VALUE_TOO_LARGE":
alert("Value is too large. Maximum is 1000");
break;
}
} catch {
// Not JSON, treat as plain string
console.error("Error:", error);
}
}
throw error;
}
}
// Wrapper for better error handling
class TauriError extends Error {
code?: string;
details?: string;
constructor(message: string, code?: string, details?: string) {
super(message);
this.name = "TauriError";
this.code = code;
this.details = details;
}
}
async function safeInvoke<T>(
command: string,
args?: Record<string, unknown>
): Promise<T> {
try {
return await invoke<T>(command, args);
} catch (error) {
if (typeof error === "string") {
try {
const parsed: ErrorResponse = JSON.parse(error);
throw new TauriError(parsed.message, parsed.code, parsed.details);
} catch {
throw new TauriError(error);
}
}
throw new TauriError("Unknown error occurred");
}
}Working with Complex Data Types
Commands can handle complex data structures including custom structs, enums, vectors, and hashmaps with automatic serialization through serde enabling rich data exchange between frontend and backend.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Structs
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u32,
username: String,
email: String,
is_active: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct UserProfile {
user: User,
bio: Option<String>,
website: Option<String>,
social_links: HashMap<String, String>,
}
// Command accepting struct
#[tauri::command]
fn create_user_profile(profile: UserProfile) -> String {
format!(
"Created profile for user ID: {} ({})",
profile.user.id, profile.user.username
)
}
// Command returning struct
#[tauri::command]
fn get_user(id: u32) -> Result<User, String> {
Ok(User {
id,
username: format!("user{}", id),
email: format!("user{}@example.com", id),
is_active: true,
})
}
// Working with vectors
#[tauri::command]
fn get_users() -> Vec<User> {
vec![
User {
id: 1,
username: "alice".to_string(),
email: "[email protected]".to_string(),
is_active: true,
},
User {
id: 2,
username: "bob".to_string(),
email: "[email protected]".to_string(),
is_active: false,
},
]
}
#[tauri::command]
fn filter_users(users: Vec<User>, active_only: bool) -> Vec<User> {
if active_only {
users.into_iter().filter(|u| u.is_active).collect()
} else {
users
}
}
// Working with enums
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Message {
Text(String),
Image { url: String, caption: Option<String> },
File { path: String, size: u64 },
}
#[tauri::command]
fn process_message(msg: Message) -> String {
match msg {
Message::Text(text) => format!("Text message: {}", text),
Message::Image { url, caption } => {
format!("Image: {} ({})", url, caption.unwrap_or_default())
}
Message::File { path, size } => {
format!("File: {} ({} bytes)", path, size)
}
}
}
// Working with HashMaps
#[tauri::command]
fn get_config() -> HashMap<String, String> {
HashMap::from([
("theme".to_string(), "dark".to_string()),
("language".to_string(), "en".to_string()),
("font_size".to_string(), "14".to_string()),
])
}
#[tauri::command]
fn update_config(
current: HashMap<String, String>,
updates: HashMap<String, String>
) -> HashMap<String, String> {
let mut merged = current;
merged.extend(updates);
merged
}
// Nested structures
#[derive(Debug, Serialize, Deserialize)]
struct Post {
id: u32,
title: String,
content: String,
author: User,
tags: Vec<String>,
metadata: HashMap<String, String>,
}
#[tauri::command]
fn create_post(
title: String,
content: String,
author: User,
tags: Vec<String>
) -> Post {
Post {
id: 1,
title,
content,
author,
tags,
metadata: HashMap::new(),
}
}
// Generic wrapper types
#[derive(Serialize, Deserialize)]
struct ApiResponse<T> {
success: bool,
data: Option<T>,
error: Option<String>,
}
#[tauri::command]
fn get_user_safe(id: u32) -> ApiResponse<User> {
if id == 0 {
return ApiResponse {
success: false,
data: None,
error: Some("Invalid user ID".to_string()),
};
}
ApiResponse {
success: true,
data: Some(User {
id,
username: format!("user{}", id),
email: format!("user{}@example.com", id),
is_active: true,
}),
error: None,
}
}Accessing Application State
Commands can access shared application state using Tauri's state management system enabling data persistence across multiple command invocations and windows. Learn more about state management patterns.
use std::sync::Mutex;
use tauri::State;
// Define application state
struct AppState {
counter: Mutex<i32>,
config: Mutex<HashMap<String, String>>,
}
// Commands accessing state
#[tauri::command]
fn increment_counter(state: State<AppState>) -> i32 {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
*counter
}
#[tauri::command]
fn get_counter(state: State<AppState>) -> i32 {
*state.counter.lock().unwrap()
}
#[tauri::command]
fn reset_counter(state: State<AppState>) {
let mut counter = state.counter.lock().unwrap();
*counter = 0;
}
#[tauri::command]
fn set_config(state: State<AppState>, key: String, value: String) {
let mut config = state.config.lock().unwrap();
config.insert(key, value);
}
#[tauri::command]
fn get_config_value(state: State<AppState>, key: String) -> Option<String> {
let config = state.config.lock().unwrap();
config.get(&key).cloned()
}
// Initialize state in main
fn main() {
let app_state = AppState {
counter: Mutex::new(0),
config: Mutex::new(HashMap::new()),
};
tauri::Builder::default()
.manage(app_state) // Register state
.invoke_handler(tauri::generate_handler![
increment_counter,
get_counter,
reset_counter,
set_config,
get_config_value,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Using Window and App Handles
Commands can access window and app handles for creating windows, emitting events, and interacting with the application instance enabling advanced features like multi-window management. Learn more about window management.
use tauri::{AppHandle, Manager, Window};
// Access window handle
#[tauri::command]
async fn close_current_window(window: Window) {
window.close().unwrap();
}
#[tauri::command]
async fn minimize_window(window: Window) {
window.minimize().unwrap();
}
#[tauri::command]
async fn set_window_title(window: Window, title: String) {
window.set_title(&title).unwrap();
}
// Access app handle
#[tauri::command]
async fn create_new_window(app: AppHandle) -> Result<(), String> {
tauri::WindowBuilder::new(
&app,
"new_window",
tauri::WindowUrl::App("index.html".into())
)
.title("New Window")
.inner_size(800.0, 600.0)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
// Emit events to frontend
#[tauri::command]
async fn notify_update(app: AppHandle, message: String) {
app.emit_all("update-notification", message).unwrap();
}
// Access specific window
#[tauri::command]
async fn send_to_window(app: AppHandle, window_label: String, message: String) {
if let Some(window) = app.get_window(&window_label) {
window.emit("message", message).unwrap();
}
}
// Get all windows
#[tauri::command]
async fn list_windows(app: AppHandle) -> Vec<String> {
app.windows()
.into_iter()
.map(|(label, _)| label)
.collect()
}Command Best Practices
- Return Results: Always use
Resulttypes for fallible operations providing meaningful error messages - Use Async: Mark commands as
asyncfor any I/O operations preventing UI blocking - Validate Input: Check and sanitize all parameters before processing preventing security issues
- Type Safety: Use strongly-typed structs instead of generic HashMaps ensuring data integrity
- Document Commands: Add doc comments explaining parameters, return values, and potential errors
- Keep Commands Focused: Each command should do one thing well following single responsibility
- Handle Errors Gracefully: Never panic in commands, always return errors to frontend
- Use Custom Errors: Define custom error types with thiserror for better error categorization
- Minimize Serialization: Transfer only necessary data reducing IPC overhead
- Test Commands: Write unit tests for command logic separately from IPC integration
async and execute them on a separate thread pool using tokio::task::spawn_blocking. This prevents blocking the async runtime while leveraging all CPU cores for heavy computation.Organizing Commands in Modules
// src-tauri/src/commands/mod.rs
pub mod user;
pub mod file;
pub mod network;
// src-tauri/src/commands/user.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: u32,
pub username: String,
pub email: String,
}
#[tauri::command]
pub fn get_user(id: u32) -> Result<User, String> {
// Implementation
Ok(User {
id,
username: format!("user{}", id),
email: format!("user{}@example.com", id),
})
}
#[tauri::command]
pub fn create_user(username: String, email: String) -> Result<User, String> {
// Implementation
Ok(User { id: 1, username, email })
}
// src-tauri/src/commands/file.rs
#[tauri::command]
pub async fn read_file(path: String) -> Result<String, String> {
tokio::fs::read_to_string(&path)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn write_file(path: String, content: String) -> Result<(), String> {
tokio::fs::write(&path, content)
.await
.map_err(|e| e.to_string())
}
// src-tauri/src/main.rs
mod commands;
use commands::{user, file, network};
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
// User commands
user::get_user,
user::create_user,
// File commands
file::read_file,
file::write_file,
// Network commands
network::fetch_data,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Next Steps
- Events System: Learn event-driven communication
- State Management: Master sharing data across commands
- File Operations: Build file system features
- HTTP Requests: Implement API integration
- Security: Apply security best practices
- IPC Patterns: Review communication fundamentals
Conclusion
Mastering Tauri command creation enables building robust desktop applications with clean backend architecture leveraging Rust's performance, safety, and ecosystem while exposing functionality to JavaScript frontends through type-safe IPC interfaces maintaining security and reliability. Commands provide foundation for all backend operations including synchronous functions for quick computations, async commands for I/O-bound tasks, error handling with Result types, complex data serialization, state management, and window integration creating comprehensive backend capabilities serving diverse application needs. Understanding command patterns including proper parameter handling, error propagation, type safety, performance optimization, and modular organization establishes solid foundation for professional Tauri development enabling creation of sophisticated desktop applications with maintainable, testable backend code executing heavy computation efficiently while maintaining responsive user interfaces throughout application lifecycle!
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


