Patterns for consistent error handling across Rust and TypeScript.
Rust Command (Result<T, E>) → tauri-specta → TypeScript discriminated union → TanStack Query/UI
Rust Result<T, E> types become TypeScript discriminated unions:
type Result<T, E> = { status: 'ok'; data: T } | { status: 'error'; error: E }For commands with one failure mode, use String errors:
#[tauri::command]
#[specta::specta]
pub async fn simple_operation() -> Result<Data, String> {
do_work().map_err(|e| format!("Operation failed: {e}"))
}For commands with multiple failure modes, use structured error enums:
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type")] // Creates TypeScript discriminated union
pub enum MyError {
NotFound,
ValidationError { message: String },
IoError { message: String },
}
impl std::fmt::Display for MyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MyError::NotFound => write!(f, "Not found"),
MyError::ValidationError { message } => write!(f, "{message}"),
MyError::IoError { message } => write!(f, "IO error: {message}"),
}
}
}
#[tauri::command]
#[specta::specta]
pub async fn production_operation() -> Result<Data, MyError> {
// ...
}TypeScript receives:
type MyError =
| { type: 'NotFound' }
| { type: 'ValidationError'; message: string }
| { type: 'IoError'; message: string }// ✅ GOOD: Handle errors inline with user feedback
const handleSave = async () => {
const result = await commands.saveData(data)
if (result.status === 'error') {
toast.error('Save failed', { description: result.error })
return
}
toast.success('Saved!')
}// ✅ GOOD: Let TanStack Query handle errors
const { data, error } = useQuery({
queryKey: ['data'],
queryFn: async () => unwrapResult(await commands.loadData()),
})// ✅ GOOD: Fall back to defaults on error
const { data } = useQuery({
queryKey: ['preferences'],
queryFn: async () => {
const result = await commands.loadPreferences()
if (result.status === 'error') {
logger.warn('Failed to load preferences, using defaults')
return defaultPreferences
}
return result.data
},
})// ✅ GOOD: Log technical details, return user-friendly message
pub async fn load_file(path: &str) -> Result<String, String> {
log::debug!("Loading file: {path}");
std::fs::read_to_string(path).map_err(|e| {
log::error!("Failed to read file {path}: {e}"); // Technical log
format!("Could not read file") // User message
})
}// ✅ GOOD: Separate user feedback from technical logging
const result = await commands.saveData(data)
if (result.status === 'error') {
logger.error('Save failed', { error: result.error, data }) // Technical
toast.error('Failed to save') // User-facing
}Configure TanStack Query retry behavior based on error type:
// ✅ GOOD: Smart retry logic
const { data } = useQuery({
queryKey: ['data'],
queryFn: loadData,
retry: (failureCount, error) => {
// Don't retry client errors (4xx)
if (error.message.includes('API error: 4')) return false
// Retry network/server errors up to 3 times
return failureCount < 3
},
})Default retry settings in query-client.ts:
| Query Type | Retries | Rationale |
|---|---|---|
| Queries | 1 | Transient failures may recover |
| Mutations | 1 | Avoid duplicate writes on slow saves |
Avoid per-query error toasts (causes duplicates). Use global handling:
// ✅ GOOD: Centralized in query-client.ts
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta?.errorToast !== false) {
toast.error('Something went wrong')
}
},
}),
})
// Opt out for specific queries
useQuery({
queryKey: ['optional-feature'],
queryFn: loadOptional,
meta: { errorToast: false },
})Error boundaries catch render errors, not async errors:
| Caught by Error Boundary | NOT Caught |
|---|---|
| Errors during render | Errors in event handlers |
| Errors in lifecycle methods | Async code (promises) |
| Errors in constructors | Errors in the error boundary itself |
For async Tauri command errors, use explicit handling or unwrapResult with TanStack Query.
For multi-step operations, rollback on failure:
// ✅ GOOD: Rollback on failure
const handleChange = async (newValue: string) => {
const oldValue = currentValue
// Step 1: Update backend
const result = await commands.updateValue(newValue)
if (result.status === 'error') {
toast.error('Update failed')
return
}
// Step 2: Persist
try {
await savePreferences.mutateAsync({ ...prefs, value: newValue })
} catch {
// Rollback step 1
await commands.updateValue(oldValue)
toast.error('Save failed, changes reverted')
}
}| Scenario | Rust Error Type | TypeScript Pattern | User Feedback |
|---|---|---|---|
| Simple command | String |
if/else + toast | Toast on error |
| Multiple failure modes | Structured enum | Match on .type |
Context-specific |
| Data fetching | Either | unwrapResult |
Query error UI |
| Optional feature | Either | Graceful degradation | Silent fallback |
| Critical operation | Structured enum | Explicit + rollback | Toast + recovery |
See also: tauri-commands.md for Result type patterns, logging.md for logging best practices.