Tauri Commands (tauri-specta)

December 18, 2025 · View on GitHub

Type-safe Tauri command bindings using tauri-specta.

Overview

This app uses tauri-specta to generate TypeScript bindings from Rust commands, providing:

  • Compile-time type checking - TypeScript catches errors before runtime
  • Auto-generated types - No manual sync between Rust and TypeScript
  • IDE autocomplete - Full IntelliSense for command names, parameters, and return types
  • Safe refactoring - Rename commands safely across the stack

Usage

Calling Commands

import { commands, type AppPreferences } from '@/lib/tauri-bindings'

// Commands return Result types for error handling
const result = await commands.loadPreferences()

if (result.status === 'ok') {
  console.log(result.data.theme) // Type-safe access
} else {
  console.error(result.error) // Type-safe error
}

Result Type Pattern

Commands that can fail return a Result<T, E> type:

type Result<T, E> = { status: 'ok'; data: T } | { status: 'error'; error: E }

See error-handling.md for comprehensive error handling patterns including structured error types, retry logic, and user feedback.

Handle both cases:

const result = await commands.savePreferences({ theme: 'dark' })

if (result.status === 'error') {
  toast.error('Failed to save', { description: result.error })
  return
}

// result.data is available here
toast.success('Saved!')

unwrapResult Helper

For cases where you want errors to propagate (throw) rather than handle them inline, use the unwrapResult helper:

import { commands, unwrapResult } from '@/lib/tauri-bindings'

// Throws on error, returns data on success
const preferences = unwrapResult(await commands.loadPreferences())

When to use each pattern:

PatternUse When
unwrapResultTanStack Query functions, errors should propagate to a boundary
Manual if/elseEvent handlers, need explicit error handling (toasts, UI state)

TanStack Query example (preferred pattern for data fetching):

import { useQuery } from '@tanstack/react-query'
import { commands, unwrapResult } from '@/lib/tauri-bindings'

const { data, error } = useQuery({
  queryKey: ['preferences'],
  queryFn: async () => unwrapResult(await commands.loadPreferences()),
})
// TanStack Query handles the thrown error automatically

Event handler example (explicit error handling):

const handleSave = async () => {
  const result = await commands.savePreferences(preferences)
  if (result.status === 'error') {
    toast.error('Failed to save', { description: result.error })
    return
  }
  toast.success('Preferences saved!')
}

Adding New Commands

1. Define the Rust command

// src-tauri/src/lib.rs

#[tauri::command]
#[specta::specta]  // Add this attribute
pub async fn my_new_command(arg: String) -> Result<MyType, String> {
    // implementation
}

2. Add Type derive to structs

use specta::Type;

#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct MyType {
    pub field: String,
}

3. Register in bindings.rs

// src-tauri/src/bindings.rs

pub fn generate_bindings() -> Builder<tauri::Wry> {
    Builder::<tauri::Wry>::new().commands(collect_commands![
        // ... existing commands
        crate::my_new_command,  // Add here
    ])
}

4. Regenerate TypeScript bindings

npm run rust:bindings

This runs cargo test export_bindings -- --ignored which generates src/lib/bindings.ts.

5. Use in frontend

import { commands, type MyType } from '@/lib/tauri-bindings'

const result = await commands.myNewCommand('arg')

6. Commit both files

Always commit:

  • Rust changes (src-tauri/src/lib.rs, src-tauri/src/bindings.rs)
  • Generated TypeScript (src/lib/bindings.ts)

File Structure

src-tauri/src/
├── lib.rs              # Commands with #[specta::specta]
├── bindings.rs         # Command registration + export test
└── Cargo.toml          # specta, tauri-specta dependencies

src/lib/
├── bindings.ts         # Generated (DO NOT EDIT)
└── tauri-bindings.ts   # Re-exports with project conventions

Known Limitations

serde_json::Value becomes JsonValue/unknown

Commands using serde_json::Value (like saveEmergencyData) have JsonValue typed parameters:

type JsonValue =
  | null
  | boolean
  | number
  | string
  | JsonValue[]
  | Partial<{ [key in string]: JsonValue }>

Cast when needed:

await commands.saveEmergencyData(filename, data as JsonValue)

Bindings generated at runtime

TypeScript bindings are generated when the app runs in debug mode, or via:

npm run rust:bindings

This must be run after changing Rust commands.

Testing

Mock the commands in tests:

// src/test/setup.ts
vi.mock('@/lib/tauri-bindings', () => ({
  commands: {
    loadPreferences: vi
      .fn()
      .mockResolvedValue({ status: 'ok', data: { theme: 'system' } }),
    savePreferences: vi.fn().mockResolvedValue({ status: 'ok', data: null }),
    // ... other commands
  },
}))

Available Commands

CommandParametersReturnsDescription
greetname: stringstringSimple greeting
loadPreferencesnoneResult<AppPreferences, string>Load preferences
savePreferencespreferences: AppPreferencesResult<null, string>Save preferences
sendNativeNotificationtitle: string, body: string | nullResult<null, string>System notification
saveEmergencyDatafilename: string, data: JsonValueResult<null, string>Save recovery data
loadEmergencyDatafilename: stringResult<JsonValue, string>Load recovery data
cleanupOldRecoveryFilesnoneResult<number, string>Cleanup old files

Dependencies

# src-tauri/Cargo.toml
specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.21", features = ["typescript"] }
specta-typescript = "=0.0.9"

Note: Using exact versions (=) during RC phase to prevent breaking changes.

References