Skip to main content

Rust frontend integration: shared types

Article from the Tauri 2 + Rust Desktop Apps series. Published 17 June 2026.

What do you think about this article?

Rust frontend integration: shared types
Tauri Rust DesktopPhotos:Godfrey AtimaPexels

At a Glance

Article from the Tauri 2 + Rust Desktop Apps series. Published 17 June 2026.

Rust frontend integration: shared types

Rust frontend integration: shared types

Tauri 2 enables you to define Rust types once, automatically generate TypeScript bindings, and guarantee total type-safety between frontend and backend. Zero runtime errors, safe refactoring, IDE autocompletion without manual API documentation. Complete guide to shared types in Tauri 2 + Rust Desktop Apps.

Answer-First: why shared types are essential in Tauri

In traditional desktop apps (Electron), JavaScript frontend and Node backend communicated via IPC with raw strings. A backend change would crash the frontend at runtime. In Tauri 2, you define Rust types once, and TypeScript understands them automatically:

  • Zero runtime errors: Rust compiler + tsc TypeScript guarantee that the type sent from frontend matches the type expected by backend.
  • Safe refactoring: If you add a field to a Rust struct, the compiler forces you to handle it in TypeScript frontend before code reaches production.
  • IDE autocompletion without manual documentation: VSCode suggests fields and methods auto-generated from TypeScript bindings, zero out-of-sync docs.
  • Reduced boilerplate: You never write duplicate JavaScript validators for data that Rust compiler already validates.
  • Tauri invocation integration: Rust commands (via `#[tauri::command]`) have type-safe signatures: frontend knows exactly what type to expect in return.

Pragmatic result: Tauri desktop apps + shared types reduce API mismatch bugs by 95%, and enable refactoring in hours instead of days.

1. Setting up a Tauri project with shared types

Start by creating a new Tauri 2 project with the template that supports shared types out-of-box:


cargo create-tauri-app --template vanilla
cd frontend-name
cargo tauri init

The resulting structure is:


project-root/
├── src-tauri/
│   ├── src/
│   │   ├── main.rs            # Entry point Rust, invocation handlers
│   │   ├── commands.rs        # Tauri commands (type-safe invocation)
│   │   └── models.rs          # Struct/enum to share
│   ├── Cargo.toml
│   └── tauri.conf.json
├── src/
│   ├── bindings.ts            # AUTO-GENERATED: TypeScript bindings
│   └── App.tsx               # Frontend React/Vue/Svelte
└── package.json

The magic happens in `bindings.ts`: Tauri reads Rust types at build time and generates matching TypeScript definitions.

2. Defining Rust structs and serializing with Serde

Rust types to share must be serializable with Serde (JSON, binary, etc.). In Tauri, you'll use JSON:


use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
    pub created_at: String,  // ISO8601 date
    pub is_premium: bool,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UserProfile {
    pub user: User,
    pub bio: String,
    pub avatar_url: Option<String>,
    pub followers_count: usize,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum UserRole {
    Admin,
    Moderator,
    User,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CreateUserRequest {
    pub name: String,
    pub email: String,
    pub password: String,  // NEVER SEND PLAINTEXT PASSWORD — hash server-side
}

The `#[serde(rename_all = "camelCase")]` attribute converts `created_at` (Rust snake_case) to `createdAt` (TypeScript camelCase), the frontend standard.

3. Type-safe Tauri commands with #[tauri::command]

Define Rust commands that the frontend will invoke. Every parameter and return value is type-checked:


use tauri::State;
use crate::models::{User, UserProfile, UserRole, CreateUserRequest};

#[tauri::command]
async fn get_user(user_id: u32) -> Result<User, String> {
    // Simulates database lookup
    if user_id == 0 {
        return Err("Invalid user ID".to_string());
    }
    Ok(User {
        id: user_id,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
        created_at: "2026-01-15T10:30:00Z".to_string(),
        is_premium: true,
    })
}

#[tauri::command]
async fn get_user_profile(user_id: u32) -> Result<UserProfile, String> {
    let user = get_user(user_id).await?;
    Ok(UserProfile {
        user,
        bio: "Software Engineer".to_string(),
        avatar_url: Some("https://example.com/avatar.jpg".to_string()),
        followers_count: 150,
    })
}

#[tauri::command]
async fn create_user(request: CreateUserRequest) -> Result<User, String> {
    // Server-side validation and password hash
    if request.email.is_empty() {
        return Err("Email required".to_string());
    }
    Ok(User {
        id: 1,
        name: request.name,
        email: request.email,
        created_at: chrono::Utc::now().to_rfc3339(),
        is_premium: false,
    })
}

Register the commands in `main.rs`:


#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_user,
            get_user_profile,
            create_user,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

4. Automatically generating TypeScript bindings

Tauri generates `bindings.ts` automatically during build. Run:


npm run tauri build
# or for dev watch
npm run tauri dev

This produces `src/bindings.ts`:


// AUTO-GENERATED by Tauri. DO NOT EDIT MANUALLY.

export interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;  // snake_case → camelCase
  isPremium: boolean;
}

export interface UserProfile {
  user: User;
  bio: string;
  avatarUrl: string | null;  // Option<T> → T | null
  followersCount: number;
}

export enum UserRole {
  Admin = 'Admin',
  Moderator = 'Moderator',
  User = 'User',
}

export interface CreateUserRequest {
  name: string;
  email: string;
  password: string;
}

These types are pure TypeScript, not runtime, so the frontend bundle doesn't bloat.

5. Invoking commands from frontend with type-safety

In your React/Vue/Svelte component, use the generated bindings:


import { invoke } from '@tauri-apps/api/core';
import type { User, UserProfile } from './bindings';

// Type-safe invocation
async function fetchUser(userId: number): Promise<User> {
  try {
    const user = await invoke<User>('get_user', { userId });
    // TypeScript knows exactly what type it receives
    console.log(user.createdAt);  // ✅ autocomplete recognizes field
    console.log(user.invalidField);  // ❌ TypeScript error!
    return user;
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw error;
  }
}

async function fetchProfile(userId: number): Promise<UserProfile> {
  const profile = await invoke<UserProfile>('get_user_profile', { userId });
  return profile;
}

// In React component
import { useEffect, useState } from 'react';

export default function ProfileComponent() {
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchProfile(123).then(setProfile).finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading...</p>;
  if (!profile) return <p>No profile found</p>;

  return (
    <div>
      <h2>{profile.user.name}</h2>
      <p>{profile.bio}</p>
      <p>Followers: {profile.followersCount}</p>
    </div>
  );
}

6. Error handling with custom types

Define Rust enum for structured errors, then handle them in TypeScript:


use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UserError {
    NotFound,
    Unauthorized,
    ValidationError(String),
    InternalError,
}

#[tauri::command]
async fn get_user(user_id: u32) -> Result<User, UserError> {
    if user_id == 0 {
        return Err(UserError::ValidationError(
            "User ID must be positive".to_string(),
        ));
    }
    // Lookup logic...
    Err(UserError::NotFound)  // Typed error
}

In the TypeScript frontend:


export enum UserError {
  NotFound = 'NOT_FOUND',
  Unauthorized = 'UNAUTHORIZED',
  ValidationError = 'VALIDATION_ERROR',
  InternalError = 'INTERNAL_ERROR',
}

async function fetchUserSafe(userId: number): Promise<User | null> {
  try {
    const user = await invoke<User>('get_user', { userId });
    return user;
  } catch (error: unknown) {
    const err = error as any;
    if (err === UserError.NotFound) {
      console.log('User not found');
    } else if (err === UserError.Unauthorized) {
      console.log('Not authorized');
    }
    return null;
  }
}

7. Advanced patterns: generics and trait bounds

Tauri also supports generic types for query/response wrapping:


#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ApiResponse<T> {
    pub status: u16,
    pub data: Option<T>,
    pub error: Option<String>,
    pub timestamp: String,
}

#[tauri::command]
async fn get_user_response(user_id: u32) -> ApiResponse<User> {
    match get_user(user_id).await {
        Ok(user) => ApiResponse {
            status: 200,
            data: Some(user),
            error: None,
            timestamp: chrono::Utc::now().to_rfc3339(),
        },
        Err(e) => ApiResponse {
            status: 400,
            data: None,
            error: Some(e),
            timestamp: chrono::Utc::now().to_rfc3339(),
        },
    }
}

Generated TypeScript:


export interface ApiResponse<T> {
  status: number;
  data: T | null;
  error: string | null;
  timestamp: string;
}

// Usage
const response = await invoke<ApiResponse<User>>('get_user_response', { userId });
if (response.status === 200 && response.data) {
  console.log(response.data.name);  // ✅ Type narrowing works
}

8. Testing with shared types

Write unit tests in both Rust and TypeScript to maintain synchronization:


#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_get_user_valid_id() {
        let result = get_user(1).await;
        assert!(result.is_ok());
        let user = result.unwrap();
        assert_eq!(user.id, 1);
        assert!(!user.name.is_empty());
    }

    #[tokio::test]
    async fn test_get_user_invalid_id() {
        let result = get_user(0).await;
        assert!(result.is_err());
    }

    #[test]
    fn test_user_serialization() {
        let user = User {
            id: 1,
            name: "Alice".to_string(),
            email: "alice@example.com".to_string(),
            created_at: "2026-01-15T10:30:00Z".to_string(),
            is_premium: true,
        };
        let json = serde_json::to_string(&user).unwrap();
        let deserialized: User = serde_json::from_str(&json).unwrap();
        assert_eq!(user.id, deserialized.id);
    }
}

In the frontend:


import { describe, it, expect, beforeEach } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import type { User } from './bindings';

describe('User API', () => {
  it('should fetch user with valid ID', async () => {
    const user: User = await invoke('get_user', { userId: 1 });
    expect(user.id).toBe(1);
    expect(user.email).toBeDefined();
  });

  it('should reject invalid user ID', async () => {
    try {
      await invoke('get_user', { userId: 0 });
      fail('Should throw error');
    } catch (err) {
      expect(err).toBeTruthy();
    }
  });
});

9. Common troubleshooting

Bindings not regenerated after Rust type change: Clean the build cache:


cargo clean
npm run tauri dev

Rust Option<T> type not mapped correctly to TypeScript: Make sure the generic type is serializable. Use `#[serde(skip_serializing_if = "Option::is_none")]` to omit None fields in JSON:


#[derive(Serialize, Deserialize)]
pub struct User {
    pub id: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar_url: Option<String>,
}

Serde version mismatch between backend and frontend: Make sure `Cargo.toml` pins a stable Serde version:


[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

TypeScript doesn't recognize bindings: Verify that `src/bindings.ts` exists and is properly imported in your TypeScript entry point.

10. Best practices

  • API versioning: Add a version field to structs to support API evolution without breaking old clients.
  • Dual validation: Validate input in Rust backend (source of truth), not just TypeScript frontend (user-friendly).
  • Avoid global mutexes: Use Tauri's `State<T>` to manage shared data between invocation handlers, it's thread-safe.
  • Structured logging: Implement Rust logging (tracing, log crate) to debug IPC communication.
  • Rate limiting: Apply rate limiting on expensive commands (DB queries, file I/O) in Rust.
  • Types changelog: Document every struct/enum change in a `TYPES_CHANGELOG.md` for team alignment.

FAQ: Shared types in Tauri 2

Q: Can I use lifetime parameters in shared types?

A: No. Tauri serializes/deserializes types via Serde, and JSON doesn't support reference semantics. Use owned types (`String` instead of `&str`).

Q: How do I handle circular references between structs?

A: Avoid them. Use IDs as foreign keys. If A references B, store `b_id: u32` and lookup separately in the backend.

Q: Are TypeScript bindings treeshakeable in the bundle?

A: Yes. They are type-only (TS), not runtime, so bundlers remove them in optimized builds.

Q: What happens if frontend and backend get out of sync?

A: Serde parsing fails on server-side or TypeScript type-cast fails (strict mode). Always regenerate after `npm run tauri dev`.

Q: Can I add custom derives to types?

A: Yes. Add custom Serde derives or use proc_macro to generate boilerplate.

Conclusion

Shared types in Tauri 2 transform desktop IPC communication from fragile and error-prone to robust and refactor-safe. You never have to worry that the frontend sends a type the backend doesn't expect: the compiler prevents it. Start simple (structs and enums), evolve toward generic types and advanced patterns as complexity grows. The result is more reliable desktop apps with fewer bugs and easier maintenance.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects