tauri-syntax-commands

Use when writing Tauri commands, calling invoke(), handling IPC errors, or streaming data from Rust to JavaScript. Prevents command registration omissions, argument type mismatches between Rust and JS

SKILL.md

GitHub

tauri-syntax-commands

Quick Reference

Command Definition (Rust)

Pattern Syntax Notes
Sync command #[tauri::command] fn name() -> T Runs on main thread
Async command #[tauri::command] async fn name() -> T Spawned on tokio runtime
Forced async #[tauri::command(async)] fn name() -> T Sync function on tokio runtime
Renamed args #[tauri::command(rename_all = "snake_case")] JS passes snake_case keys

Argument Types (Rust <- JS)

Rust Type JS Type Notes
i32, u64, f64 number Direct mapping
bool boolean Direct mapping
String string Owned string
Vec<T> T[] Array to Vec
Option<T> T | null | undefined Nullable
PathBuf string Path string
Custom struct object Must #[derive(Deserialize)]

Return Types (Rust -> JS)

Rust Return JS Result Notes
() void / null No return value
T (Serialize) T Direct serialization
Result<T, E> T or throw E E must impl Serialize
Response ArrayBuffer Binary data bypass

Special Injected Parameters (NOT passed from JS)

Rust Parameter Purpose Example
app: AppHandle Application handle app.emit("event", data)
window: WebviewWindow Calling window window.label()
state: State<'_, T> Managed state state.inner()
request: tauri::ipc::Request Raw IPC request request.headers(), request.body()

Frontend invoke() API

Feature Syntax
Import import { invoke } from '@tauri-apps/api/core'
Basic call await invoke('command_name')
With args await invoke('cmd', { argName: value })
Typed return await invoke<ReturnType>('cmd')
With headers await invoke('cmd', args, { headers: {...} })
Binary data await invoke('cmd', new Uint8Array([...]))

Critical Warnings

NEVER use snake_case keys in JS invoke() arguments -- Tauri auto-converts camelCase to snake_case. Passing { file_path: "x" } will NOT match Rust parameter file_path.

NEVER forget to implement Serialize on error types -- Result<T, E> requires E to implement Serialize to send errors to the frontend.

NEVER use &str parameters in async commands -- borrowed references cannot cross the async spawn boundary. Use String instead.

NEVER register commands with multiple invoke_handler() calls -- only the last one takes effect.

NEVER mark commands in lib.rs as pub -- glue code generation prevents it. Move to a separate module if pub is needed.

ALWAYS wrap invoke() calls in try/catch -- Rust Result::Err rejects the JS Promise.

ALWAYS use Channel<T> for streaming data instead of multiple events -- channels are tied to the command invocation and type-safe.

ALWAYS derive serde::Deserialize on custom argument types and serde::Serialize on custom return types.


Essential Patterns

Pattern 1: Basic Command (Rust + TypeScript)

// src-tauri/src/commands.rs
#[tauri::command]
pub fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}
// src/main.ts
import { invoke } from '@tauri-apps/api/core';

const greeting = await invoke<string>('greet', { name: 'World' });
console.log(greeting); // "Hello, World!"

Pattern 2: Async Command with Error Handling

#[derive(Debug, thiserror::Error)]
enum Error {
    #[error(transparent)]
    Io(#[from] std::io::Error),
    #[error("not found: {0}")]
    NotFound(String),
}

// REQUIRED: Serialize impl for sending errors to frontend
impl serde::Serialize for Error {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where S: serde::ser::Serializer {
        serializer.serialize_str(self.to_string().as_ref())
    }
}

#[tauri::command]
async fn read_file(path: String) -> Result<String, Error> {
    let content = tokio::fs::read_to_string(&path).await?;
    Ok(content)
}
try {
    const content = await invoke<string>('read_file', { path: '/tmp/data.txt' });
    console.log(content);
} catch (error) {
    // error is the serialized string: "not found: ..." or "io error: ..."
    console.error('Failed:', error);
}

Pattern 3: Structured Error Types

For richer error handling on the frontend, use tagged enum serialization.

#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum AppError {
    Io(String),
    NotFound(String),
    Unauthorized(String),
}

#[tauri::command]
async fn load_data(id: u32) -> Result<Data, AppError> {
    if id == 0 {
        return Err(AppError::NotFound("Item not found".into()));
    }
    Ok(Data { /* ... */ })
}
try {
    const data = await invoke<Data>('load_data', { id: 42 });
} catch (err: any) {
    // err = { kind: 'notFound', message: 'Item not found' }
    switch (err.kind) {
        case 'notFound':
            showNotFoundUI(err.message);
            break;
        case 'unauthorized':
            redirectToLogin();
            break;
        default:
            console.error('Unexpected error:', err);
    }
}

Pattern 4: Special Injected Parameters

These parameters are injected by Tauri -- the frontend does NOT pass them.

use tauri::{AppHandle, State, WebviewWindow};

#[tauri::command]
async fn complex_command(
    app: AppHandle,                       // injected: app handle
    window: WebviewWindow,                // injected: calling window
    state: State<'_, MyState>,            // injected: managed state
    // Regular parameters (passed from JS):
    query: String,
    limit: Option<u32>,
) -> Result<Vec<Item>, String> {
    println!("Called from window: {}", window.label());
    let config = state.inner();
    // ... use app, window, state as needed
    Ok(vec![])
}
// Frontend only passes the regular parameters -- injected params are omitted
const items = await invoke<Item[]>('complex_command', {
    query: 'search term',
    limit: 10,
});

Pattern 5: Channel Streaming (Rust -> JS)

use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize)]
struct DownloadProgress {
    bytes_read: u64,
    total: u64,
    percent: f64,
}

#[tauri::command]
async fn download_file(
    url: String,
    on_progress: Channel<DownloadProgress>,
) -> Result<String, String> {
    let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
    let total = response.content_length().unwrap_or(0);
    let mut bytes_read = 0u64;

    // Stream progress updates
    on_progress.send(DownloadProgress {
        bytes_read,
        total,
        percent: 0.0,
    }).unwrap();

    // ... read chunks, updating progress ...

    Ok("download complete".into())
}
import { invoke, Channel } from '@tauri-apps/api/core';

interface DownloadProgress {
    bytesRead: number;
    total: number;
    percent: number;
}

const onProgress = new Channel<DownloadProgress>();
onProgress.onmessage = (progress) => {
    updateProgressBar(progress.percent);
    console.log(`${progress.bytesRead}/${progress.total}`);
};

const result = await invoke<string>('download_file', {
    url: 'https://example.com/file.zip',
    onProgress,  // Pass the channel as a regular argument
});

Pattern 6: Binary Data with Request/Response

Bypass JSON serialization for large binary payloads.

use tauri::ipc::{Request, Response};

// Receiving binary data from JS
#[tauri::command]
fn upload(request: Request) -> Result<String, String> {
    let tauri::ipc::InvokeBody::Raw(data) = request.body() else {
        return Err("Expected binary data".into());
    };
    let auth = request.headers().get("Authorization");
    println!("Received {} bytes", data.len());
    Ok(format!("Uploaded {} bytes", data.len()))
}

// Returning binary data to JS
#[tauri::command]
fn get_image(path: String) -> Response {
    let data = std::fs::read(path).unwrap();
    Response::new(data)
}
// Sending binary data
const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await invoke<string>('upload', data, {
    headers: { Authorization: 'Bearer token123' },
});

// Receiving binary data
const imageData = await invoke<ArrayBuffer>('get_image', { path: '/photo.jpg' });

Pattern 7: Command Registration

// src-tauri/src/lib.rs
mod commands;

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

Rule: ALL commands MUST be listed in a single generate_handler![] macro call. Multiple invoke_handler() calls do NOT merge.


Argument Name Mapping

Tauri automatically converts between JS camelCase and Rust snake_case.

Rust Parameter JS Argument Key
file_path: String filePath
user_name: String userName
item_count: u32 itemCount
is_active: bool isActive

To override this behavior:

#[tauri::command(rename_all = "snake_case")]
fn my_command(file_path: String) { }
// With rename_all = "snake_case", pass snake_case keys
await invoke('my_command', { file_path: '/tmp/file.txt' });

Channel vs Events

Feature Channel Events
Direction Rust -> JS (response stream) Bidirectional
Lifetime Tied to command invocation App lifetime
Targeting Specific caller Broadcast or filtered
Type safety Generic Channel<T> Serialized JSON
Use case Progress, streaming data App-wide notifications

ALWAYS use Channel<T> for progress updates and streaming data from a command. NEVER use events for command-specific streaming -- channels are scoped to the invocation.


Runtime Generics (Plugin Authors)

When writing commands for plugins or libraries that need to be runtime-agnostic:

use tauri::{AppHandle, Runtime, WebviewWindow};

#[tauri::command]
async fn plugin_command<R: Runtime>(
    app: AppHandle<R>,
    window: WebviewWindow<R>,
    data: String,
) -> Result<String, String> {
    Ok(format!("Processed: {}", data))
}

With the default wry feature, R resolves to Wry. This generic is only necessary for plugin/library code.


Reference Links

Official Sources