Thomas Pedot
dimanche 22 mars 2026
Type-Safe Rust ↔ TypeScript IPC in Tauri with tauri-specta

Tauri's inter-process communication layer is powerful, but its default API is stringly typed. Every call goes through invoke("command_name", { arg: value }) — a string for the command name, a plain object for the arguments, and an untyped Promise<unknown> on return. Rename a Rust function, add a parameter, or change a return type, and TypeScript has no idea until the app throws at runtime.
tauri-specta eliminates this entirely. It reads #[specta::specta] annotations on your Rust command handlers and generates a TypeScript module with fully typed function wrappers. The IPC contract lives in Rust; the TypeScript side is derived from it automatically. When the two sides disagree, the build fails.
This article walks through the setup used in GitAlchemy and the specific patterns that make it work in a monorepo with TanStack Query.
The Problem with Raw invoke
The naive approach works for small demos but falls apart at scale:
// No types — what does this return? What if "load_preferences" is renamed?
const prefs = await invoke("load_preferences");
// Typo: "save_preferenses" — compiles fine, fails at runtime
await invoke("save_preferenses", { preferences: newPrefs });Neither TypeScript nor your IDE can help here. The command name is opaque, the argument shape is unknown, and the return type defaults to unknown. You're essentially writing untyped RPC with string keys.
What tauri-specta Does
tauri-specta hooks into Tauri's command collection mechanism. Instead of passing your command list directly to .invoke_handler(), you first register it with a Builder that tracks type information. When you call .export(), it writes a .ts file containing:
- A typed
commandsobject with one async function per Rust command - TypeScript equivalents of every Rust type that appears in any command signature
- A
Result<T, E>discriminated union that mirrors Rust'sResult
The generated bindings are a plain TypeScript module. There's no runtime reflection, no proxy magic — just generated code that wraps invoke with the exact types derived from your Rust signatures.
Setup
Add the three crates to your Cargo.toml:
[dependencies]
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"Pin to exact versions. The rc releases are not semver-stable and minor version bumps have broken things in practice.
Annotating Commands
Add #[specta::specta] alongside #[tauri::command]. Every type that appears in the signature — parameters and return values — must implement specta::Type. For structs and enums, derive it:
1use serde::{Deserialize, Serialize};
2use specta::Type;
3
4#[derive(Debug, Clone, Serialize, Deserialize, Type)]
5pub struct AppPreferences {
6 pub theme: String,
7 pub quick_pane_shortcut: Option<String>,
8 pub language: Option<String>,
9}
10
11#[tauri::command]
12#[specta::specta]
13pub async fn load_preferences(app: AppHandle) -> Result<AppPreferences, String> {
14 // ...
15}
16
17#[tauri::command]
18#[specta::specta]
19pub async fn save_preferences(app: AppHandle, preferences: AppPreferences) -> Result<(), String> {
20 // ...
21}The Type derive macro is what gives specta the runtime type information it needs to emit correct TypeScript.
The Builder and Export
In GitAlchemy, command registration and binding export live in a dedicated bindings.rs module:
1use tauri_specta::{collect_commands, Builder};
2
3pub fn generate_bindings() -> Builder<tauri::Wry> {
4 use crate::commands::{preferences, recovery};
5
6 Builder::<tauri::Wry>::new().commands(collect_commands![
7 preferences::load_preferences,
8 preferences::save_preferences,
9 recovery::save_emergency_data,
10 recovery::load_emergency_data,
11 recovery::cleanup_old_recovery_files,
12 ])
13}
14
15pub fn export_ts_bindings() {
16 generate_bindings()
17 .export(
18 specta_typescript::Typescript::default()
19 .header("// @ts-nocheck\n// Auto-generated by tauri-specta. DO NOT EDIT.\n\n"),
20 "../src/lib/bindings.ts",
21 )
22 .expect("Failed to export TypeScript bindings");
23}In lib.rs, the builder feeds both the export and the invoke handler:
1pub fn run() {
2 let builder = bindings::generate_bindings();
3
4 // Export on every debug build — the file stays in sync automatically
5 #[cfg(all(debug_assertions, desktop))]
6 bindings::export_ts_bindings();
7
8 tauri::Builder::default()
9 // ... plugins ...
10 .invoke_handler(builder.invoke_handler())
11 .run(tauri::generate_context!())
12 .expect("error while running tauri application");
13}The #[cfg(all(debug_assertions, desktop))] guard means bindings regenerate on every cargo tauri dev run. In production builds and on Android (where the filesystem is read-only), the export is skipped entirely.
The Generated TypeScript
The output at src/lib/bindings.ts looks like this:
1export const commands = {
2 async loadPreferences(): Promise<Result<AppPreferences, string>> {
3 try {
4 return { status: "ok", data: await TAURI_INVOKE("load_preferences") };
5 } catch (e) {
6 if (e instanceof Error) throw e;
7 else return { status: "error", error: e as any };
8 }
9 },
10 async savePreferences(preferences: AppPreferences): Promise<Result<null, string>> {
11 try {
12 return {
13 status: "ok",
14 data: await TAURI_INVOKE("save_preferences", { preferences }),
15 };
16 } catch (e) {
17 if (e instanceof Error) throw e;
18 else return { status: "error", error: e as any };
19 }
20 },
21};
22
23export type AppPreferences = {
24 theme: string;
25 quick_pane_shortcut: string | null;
26 language: string | null;
27};
28
29export type Result<T, E> = { status: "ok"; data: T } | { status: "error"; error: E };This file is committed to the repository. It's generated code, but treating it as a build artifact (gitignored) would break type checking in CI without running the Rust toolchain. Committing it means tsc works anywhere.
Typed Error Variants with Rust Enums
For commands where distinct error cases matter on the frontend, returning Result<T, String> throws away structure. tauri-specta handles Rust enums too. In GitAlchemy's recovery module:
1#[derive(Debug, Clone, Serialize, Deserialize, Type)]
2#[serde(tag = "type")]
3pub enum RecoveryError {
4 FileNotFound,
5 ValidationError { message: String },
6 DataTooLarge { max_bytes: u32 },
7 IoError { message: String },
8 ParseError { message: String },
9}
10
11#[tauri::command]
12#[specta::specta]
13pub async fn load_emergency_data(app: AppHandle, filename: String) -> Result<Value, RecoveryError> {
14 // ...
15}The generated TypeScript is a proper discriminated union:
1export type RecoveryError =
2 | { type: "FileNotFound" }
3 | { type: "ValidationError"; message: string }
4 | { type: "DataTooLarge"; max_bytes: number }
5 | { type: "IoError"; message: string }
6 | { type: "ParseError"; message: string };On the frontend, TypeScript narrows correctly in a switch on error.type. No string parsing, no as any casts.
Consuming the Bindings with TanStack Query
GitAlchemy wraps the generated commands in TanStack Query hooks. The bindings are re-exported through a thin tauri-bindings.ts module that adds a convenience unwrapResult helper:
1export { commands, type Result } from "./bindings";
2export type { AppPreferences, RecoveryError } from "./bindings";
3
4export function unwrapResult<T, E>(
5 result: { status: "ok"; data: T } | { status: "error"; error: E },
6): T {
7 if (result.status === "ok") return result.data;
8 throw result.error;
9}The query hook:
1import { commands, type AppPreferences } from "@/lib/tauri-bindings";
2
3export function usePreferences() {
4 return useQuery({
5 queryKey: ["preferences"],
6 queryFn: async (): Promise<AppPreferences> => {
7 const result = await commands.loadPreferences();
8 if (result.status === "error") {
9 return { theme: "dark", quick_pane_shortcut: null, language: null };
10 }
11 return result.data;
12 },
13 });
14}
15
16export function useSavePreferences() {
17 const queryClient = useQueryClient();
18 return useMutation({
19 mutationFn: async (preferences: AppPreferences) => {
20 const result = await commands.savePreferences(preferences);
21 if (result.status === "error") throw new Error(result.error);
22 },
23 onSuccess: (_, preferences) => {
24 queryClient.setQueryData(["preferences"], preferences);
25 },
26 });
27}commands.loadPreferences() returns Promise<Result<AppPreferences, string>>. The type is known at the call site. If AppPreferences gains a new required field in Rust, the TypeScript compiler will flag every place that constructs a default value manually — including that fallback object in the query function.
Trade-offs
tauri-specta adds a codegen step to the workflow. After changing a Rust command signature, you need to run the generator (or let the debug build do it) before TypeScript picks up the change. In GitAlchemy, the debug-build auto-export handles this invisibly during development. In CI, the bindings file is committed, so no Rust compilation is needed in the TypeScript pipeline.
The rc version pinning is the other friction point. tauri-specta is pre-1.0 and the release candidates are not backwards-compatible. Upgrading requires checking the changelog and re-testing generated output. For now, exact version pins in Cargo.toml are the pragmatic choice.
Neither of these is a significant burden. The alternative — maintaining string-based IPC manually across a growing command surface — compounds over time in a way that a periodic codegen run does not.
This is one layer of the architecture covered in detail in the GitAlchemy production app breakdown, which goes into the full Tauri + React + TypeScript + Turborepo setup.
If you want to see the result in practice, GitAlchemy is available for Linux, macOS, Windows, and Android.