Cambi uno struct Rust e la tua interfaccia si rompe a runtime
Fra il codice Rust e il frontend di un'app Tauri non c'è un linguaggio in comune: c'è JSON. Se rinomini un campo di uno struct, il
compilatore Rust è soddisfatto, TypeScript pure, e l'errore compare quando l'utente apre la schermata. Tauri 2 non genera da solo i
tipi TypeScript dai comandi Rust: invoke<User>(...) è una dichiarazione che scrivi tu, non una verifica. Hai tre
strade per tenere allineati i due lati: scrivere lo specchio TypeScript a mano e proteggerlo con una guardia a runtime, generarlo
con ts-rs, oppure generarlo insieme alle funzioni di chiamata con tauri-specta. La scelta dipende da
quanti comandi hai.
Che cosa ho provato: la parte TypeScript l'ho eseguita con Node 24 e con le funzioni di mock di @tauri-apps/api 2.12.4, e l'ho
controllata con tsc in modalità strict. La parte Rust non l'ho compilata, perché sulla macchina di scrittura
non ci sono Rust né Cargo: i listati Rust sono costruiti sugli esempi della documentazione ufficiale e li devi adattare. Il punto di
partenza è
Tauri 2 o Electron, dove trovi il quadro dell'architettura.
Che cosa attraversa davvero il confine
La documentazione descrive l'IPC di Tauri come scambio asincrono di messaggi, con un protocollo «JSON-RPC like»: ogni argomento e ogni
valore di ritorno deve essere serializzabile in JSON. In termini di Rust, gli argomenti di un comando implementano
serde::Deserialize e i valori di ritorno implementano serde::Serialize. Il frontend non vede mai uno struct:
vede un oggetto JavaScript con i campi che Serde ha prodotto.
Vale la pena fissare due regole di naming, perché sono la prima fonte di confusione. I nomi degli argomenti del comando passano in
camelCase per impostazione predefinita: la funzione get_user(user_id: u32) si chiama con { userId: 1 },
e se vuoi lo snake_case lo chiedi con #[tauri::command(rename_all = "snake_case")]. I nomi dei campi di uno struct che attraversa il
confine sono invece decisi da Serde: #[serde(rename_all = "camelCase")] trasforma created_at in createdAt.
Sono due meccanismi separati, e il secondo non cambia i nomi del primo.
// src-tauri/src/commands.rs
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: u32,
pub name: String,
pub email: String,
pub created_at: String,
pub is_premium: bool,
pub avatar_url: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("utente {0} non trovato")]
NotFound(u32),
#[error("identificatore non valido")]
InvalidId,
}
#[derive(Serialize)]
#[serde(tag = "kind", content = "message", rename_all = "camelCase")]
enum ErrorKind {
NotFound(String),
InvalidId(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let message = self.to_string();
let kind = match self {
Self::NotFound(_) => ErrorKind::NotFound(message),
Self::InvalidId => ErrorKind::InvalidId(message),
};
kind.serialize(serializer)
}
}
#[tauri::command]
pub fn get_user(user_id: u32) -> Result<User, Error> {
match user_id {
0 => Err(Error::InvalidId),
1 => Ok(User {
id: 1,
name: "Giulia".into(),
email: "giulia@example.org".into(),
created_at: "2026-01-15T10:30:00Z".into(),
is_premium: true,
avatar_url: None,
}),
other => Err(Error::NotFound(other)),
}
}
// 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::get_user])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Lo struct User deriva Serialize perché viaggia verso il frontend; Option<String> diventa
null quando vale None. L'errore usa thiserror per il messaggio e un tipo di appoggio,
ErrorKind, per decidere la forma in JSON: con tag = "kind" e content = "message" ottieni un oggetto
{ "kind": "notFound", "message": "..." }, la rappresentazione «adiacente» descritta nella documentazione di Serde. È lo stesso schema che
la guida di Tauri sui comandi propone per mappare gli errori Rust su un'enumerazione TypeScript.
Tre dettagli del codice vengono dalla documentazione e fanno perdere tempo. Un comando definito in lib.rs non può essere
pub (altrimenti compare l'errore E0255), mentre un comando in un modulo separato come qui deve esserlo. Il nome del
comando non include il modulo: nel frontend chiami get_user, non commands::get_user. E invoke_handler
va chiamato una sola volta con tutti i comandi dentro generate_handler!, perché solo l'ultima chiamata resta valida.
Se un comando fa lavoro lungo, dichiaralo async: i comandi sincroni girano sul thread principale e bloccano l'interfaccia. Gli
argomenti presi in prestito (&str, State<'_, T>) non si combinano con async senza
accorgimenti: la soluzione più semplice è passare un String. Per capire che cosa succede sotto un async fn, leggi
Rust Async e Tokio: Future, Pin, Waker.
Perché invoke<User> non ti protegge
Il generico di invoke dice a TypeScript che cosa aspettarsi, ma non controlla nulla. Se il backend manda un oggetto con un
campo in meno, il compilatore non lo sa e l'errore arriva più tardi, lontano dalla causa. La soluzione senza strumenti è scrivere lo
specchio dei tipi e una funzione che lo verifica quando la risposta arriva.
import { invoke } from '@tauri-apps/api/core';
// Specchio scritto a mano di ciò che serde produce dallo struct Rust `User`.
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
isPremium: boolean;
avatarUrl: string | null;
}
// Specchio dell'errore Rust serializzato con #[serde(tag = "kind", content = "message")].
export type UserError =
| { kind: 'notFound'; message: string }
| { kind: 'invalidId'; message: string };
export function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === 'number' &&
typeof v.name === 'string' &&
typeof v.email === 'string' &&
typeof v.createdAt === 'string' &&
typeof v.isPremium === 'boolean' &&
(v.avatarUrl === null || typeof v.avatarUrl === 'string')
);
}
export function isUserError(value: unknown): value is UserError {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (v.kind === 'notFound' || v.kind === 'invalidId') && typeof v.message === 'string';
}
export async function getUser(userId: number): Promise<User> {
const raw = await invoke<unknown>('get_user', { userId });
if (!isUser(raw)) {
throw new TypeError(`risposta di get_user non conforme: ${JSON.stringify(raw)}`);
}
return raw;
}
User e UserError sono lo specchio, scritto a mano, di ciò che Serde produce nel listato Rust: nota
avatarUrl: string | null, perché None diventa null. isUser e isUserError sono due
guardie di tipo: restituiscono true solo se la forma corrisponde. getUser chiama il comando chiedendo
unknown a invoke, passa il risultato alla guardia e lancia un TypeError con il contenuto ricevuto se non
combacia. Il costo cresce con la dimensione della risposta: su una lista molto lunga puoi controllare il solo primo elemento.
Per vederla funzionare senza avviare l'app serve un finto backend. Il modulo @tauri-apps/api/mocks offre mockIPC, che
intercetta le chiamate di invoke e le passa a una funzione tua.
import { mockIPC, clearMocks } from '@tauri-apps/api/mocks';
import { getUser, isUserError } from './bindings.ts';
(globalThis as any).window = globalThis;
// Il gestore simula il core Rust: restituisce il JSON che serde produrrebbe.
mockIPC((cmd, args) => {
console.log('IPC ->', cmd, JSON.stringify(args));
if (cmd !== 'get_user') return;
const userId = (args as { userId: number }).userId;
if (userId === 0) throw { kind: 'invalidId', message: 'identificatore non valido' };
if (userId === 404) throw { kind: 'notFound', message: 'utente 404 non trovato' };
const completo = { id: userId, name: 'Giulia', email: 'giulia@example.org', createdAt: '2026-01-15T10:30:00Z', isPremium: true, avatarUrl: null };
if (userId === 7) { const { createdAt, isPremium, ...manca } = completo; return manca; } // mancano due campi
if (userId === 9) { const { avatarUrl, ...omesso } = completo; return omesso; } // campo Option omesso
return completo;
});
for (const id of [1, 0, 404, 7, 9]) {
try {
const user = await getUser(id);
console.log('ok ', user.name, user.createdAt);
} catch (e) {
if (isUserError(e)) console.log('errore ', e.kind, '-', e.message);
else console.log('imprevisto', (e as Error).name + ':', (e as Error).message);
}
}
clearMocks();
// Il limite di JSON: un intero oltre 2^53 non sopravvive al passaggio in JavaScript.
console.log(JSON.parse('{"bytes": 9007199254740993}').bytes);
console.log(Number.MAX_SAFE_INTEGER);
// Output (Node 24, due esecuzioni identiche):
// IPC -> get_user {"userId":1}
// ok Giulia 2026-01-15T10:30:00Z
// IPC -> get_user {"userId":0}
// errore invalidId - identificatore non valido
// IPC -> get_user {"userId":404}
// errore notFound - utente 404 non trovato
// IPC -> get_user {"userId":7}
// imprevisto TypeError: risposta di get_user non conforme: {"id":7,"name":"Giulia","email":"giulia@example.org","avatarUrl":null}
// IPC -> get_user {"userId":9}
// imprevisto TypeError: risposta di get_user non conforme: {"id":9,"name":"Giulia","email":"giulia@example.org","createdAt":"2026-01-15T10:30:00Z","isPremium":true}
// 9007199254740992
// 9007199254740991
La riga (globalThis as any).window = globalThis serve solo fuori dal browser, perché mockIPC cerca
window. Il gestore simula il core Rust restituendo il JSON che, secondo la documentazione di Serde, produrrebbero lo struct e
l'errore del listato Rust: è una mia assunzione, non l'uscita di un binario Tauri vero, ed è per questo che il passaggio successivo è un test lato Rust.
Leggi l'uscita. L'utente 1 arriva integro; gli utenti 0 e 404 producono errori tipizzati, che isUserError riconosce. I casi 7 e 9 sono
quelli utili: nel 7 mancano due campi, nel 9 manca avatarUrl, e in entrambi la guardia lancia un TypeError che il
catch classifica come imprevisto, con il contenuto ricevuto accanto al messaggio.
Il caso 9 corrisponde a un errore reale: se sul campo Rust metti #[serde(skip_serializing_if = "Option::is_none")], un
None non diventa null ma sparisce dal JSON, e il tuo tipo string | null è falso. In quel caso il campo va
dichiarato opzionale (avatarUrl?: string) e la guardia va adattata. Le ultime due righe dell'uscita mostrano un altro limite di
JSON: un intero oltre 253 come 9007199254740993 diventa 9007199254740992 appena JSON.parse lo legge in JavaScript.
Gli errori: che cosa arriva nel catch
Quando un comando restituisce Err, la promessa di invoke viene rifiutata con il valore serializzato dell'errore. Un
errore di tipo String è il caso più semplice; il tipo con kind e message del listato Rust permette al frontend di
distinguere i casi con un'unione TypeScript discriminata, e di mostrare un messaggio diverso per «non trovato» e per «identificatore non valido».
Attenzione al verso opposto. Un rifiuto può nascere anche dal framework, per esempio quando il comando non esiste, gli argomenti non sono deserializzabili o
un permesso lo nega, e dal lato JavaScript le due cose non si distinguono per forma. La documentazione di tauri-specta lo dice
esplicitamente. Per questo l'esempio controlla sempre isUserError prima di usare kind, e tratta tutto il resto come imprevisto.
Il contratto si fissa con un test Rust
Lo specchio TypeScript è corretto solo se il JSON reale ha quella forma, e questo si verifica dal lato che lo produce. Serde espone
serde_json::to_value, con cui puoi confrontare l'output con un letterale scritto a mano.
// in fondo a src-tauri/src/commands.rs
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn user_serializza_con_campi_camel_case() {
let user = get_user(1).unwrap();
assert_eq!(
serde_json::to_value(&user).unwrap(),
json!({
"id": 1,
"name": "Giulia",
"email": "giulia@example.org",
"createdAt": "2026-01-15T10:30:00Z",
"isPremium": true,
"avatarUrl": null
})
);
}
#[test]
fn errore_serializza_come_kind_message() {
let error = get_user(404).unwrap_err();
assert_eq!(
serde_json::to_value(&error).unwrap(),
json!({ "kind": "notFound", "message": "utente 404 non trovato" })
);
}
}
Il primo test controlla che i nomi dei campi siano in camelCase e che avatarUrl valga null; il secondo controlla la forma
kind/message dell'errore. Servono serde_json fra le dipendenze di sviluppo e Debug su User,
perché unwrap_err lo richiede. Il letterale del primo test è lo stesso oggetto che il gestore di mockIPC restituisce per
l'utente 1: quando i due coincidono, lo specchio TypeScript e il Rust descrivono la stessa cosa. Non ho eseguito questo test.
Generare i tipi invece di scriverli
Con pochi comandi lo specchio a mano basta. Quando i comandi diventano decine, tenere allineati due file a occhio non regge e conviene far generare i tipi dal codice Rust. Gli strumenti che ho trovato documentati hanno compromessi diversi:
- ts-rs (versione 12.0.1 su crates.io, ultima pubblicazione 2026-01-31): deriva i tipi TypeScript dagli struct, comprende gli attributi Serde, non conosce i comandi di Tauri.
- tauri-specta (2.0.0-rc.28 al 2026-11-11, per Tauri 2): genera i tipi e anche le funzioni di chiamata dei comandi, ma la serie 2 è ancora candidata al rilascio e richiede versioni esatte di
specta. - taurpc (0.8.2): elencato fra i plugin della comunità come «Typesafe IPC wrapper for Tauri commands and events»; non l'ho provato.
La differenza pratica sta in che cosa ti lasciano scrivere. Con ts-rs ottieni i tipi ma le chiamate a invoke restano tue, quindi nome del
comando e nome degli argomenti non sono controllati. Con tauri-specta ottieni un oggetto commands con una funzione per comando, e un errore di
battitura nel nome diventa un errore di compilazione. Il prezzo è una dipendenza in versione candidata, da bloccare con il segno =. La versione
1.0.2 della libreria, ricorda il suo README, non supporta Tauri 2.
// Cargo.toml: ts-rs = "12.0"
#[derive(Debug, serde::Serialize, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct User {
pub id: u32,
pub name: String,
pub email: String,
pub created_at: String,
pub is_premium: bool,
pub avatar_url: Option<String>,
}
Con #[ts(export)] la libreria genera un test che scrive il file dei tipi quando lanci cargo test, in una cartella decisa dalla
variabile d'ambiente TS_RS_EXPORT_DIR. Il formato prodotto è un type TypeScript, non un'interfaccia, e il README precisa che con la
funzione serde-compat (attiva per impostazione predefinita) gli attributi Serde come rename_all e skip_serializing_if sono letti.
# src-tauri/.cargo/config.toml
[env]
TS_RS_EXPORT_DIR = { value = "../src/bindings", relative = true }
TS_RS_LARGE_INT = "number"
Il file di configurazione di Cargo, che ho validato come TOML, sposta l'esportazione nella cartella del frontend. La riga più delicata è
TS_RS_LARGE_INT: per i tipi a 64 bit (i64, u64) il valore predefinito è bigint, ma JSON non trasporta un
bigint e JavaScript riceverebbe un numero, quindi la dichiarazione mentirebbe. Impostandolo a "number" la dichiarazione è onesta
finché il valore sta sotto 253, come mostra l'ultima riga dell'uscita sopra; oltre, la soluzione sicura è inviare l'identificativo come stringa.
// Cargo.toml (versioni rc: il segno = le blocca)
// specta = "=2.0.0-rc.28"
// specta-typescript = "0.0.15"
// tauri-specta = { version = "=2.0.0-rc.28", features = ["derive", "typescript"] }
// in commands.rs: aggiungi specta::Type ai tipi e #[specta::specta] al comando
#[derive(Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct User { /* come sopra */ }
#[tauri::command]
#[specta::specta]
pub fn get_user(user_id: u32) -> Result<User, Error> { /* come sopra */ }
// in lib.rs
use specta_typescript::Typescript;
use tauri_specta::{collect_commands, Builder};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = Builder::<tauri::Wry>::new()
.commands(collect_commands![commands::get_user]);
#[cfg(debug_assertions)]
builder
.export(Typescript::default(), "../src/bindings.ts")
.expect("impossibile esportare i binding TypeScript");
tauri::Builder::default()
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
builder.mount_events(app);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Seguendo l'esempio di docs.rs, annoti ogni comando con #[specta::specta], li registri con collect_commands! e fai esportare il file
bindings.ts solo nelle build di debug (#[cfg(debug_assertions)]), così il file generato si riscrive mentre sviluppi e non pesa sulla build di rilascio.
builder.invoke_handler() prende il posto del generate_handler! di Tauri. Nel frontend chiami commands.getUser(1). Per impostazione
predefinita un comando che restituisce Err fa rifiutare la promessa; l'altra modalità, ErrorHandlingMode::Result, restituisce invece un oggetto
{ status: "ok" | "error" }. Dopo la prima esportazione apri il file e controlla i nomi dei campi: è la verifica che mi sento di raccomandare con una libreria in versione candidata.
Quando JSON non basta: file grandi e flussi
Restituire un file come Vec<u8> lo serializza in JSON, e con file grandi rallenta l'app. La documentazione indica
tauri::ipc::Response per restituire buffer di byte senza passare da JSON. Per flussi continui, come l'avanzamento di un download,
lo strumento raccomandato è il canale (tauri::ipc::Channel), che spedisce i pezzi al frontend man mano che sono pronti. Entrambi escono dal mondo dei tipi condivisi
come li abbiamo visti, e vanno protetti con test propri.
Errori che costano un pomeriggio
Questi sono i problemi che ho ricavato dalla documentazione e dall'esecuzione degli esempi, senza un ordine di gravità:
- Chiamare il comando con argomenti in
snake_casequando il comando si aspetta camelCase. - Segnare
pubun comando dentrolib.rs, che produce l'erroreE0255. - Chiamare
invoke_handlerpiù volte: conta solo l'ultima chiamata. - Usare
&stroStatecome argomento di un comandoasync. - Dichiarare
string | nullsu un campo conskip_serializing_if, che in realtà sparisce dal JSON. - Passare interi a 64 bit oltre 253 come numeri.
Quasi tutte nascono dallo stesso equivoco: pensare che i due lati condividano tipi, quando condividono soltanto un testo JSON. Le prime quattro si scoprono di solito alla prima prova del comando; le ultime due sono silenziose, e per questo le guardie a runtime e i test sulla forma del JSON valgono più del controllo dei tipi.
Domande frequenti
Tauri genera i binding TypeScript da solo?
No. Nella documentazione di Tauri 2 non c'è una generazione automatica dei tipi dei comandi: gli strumenti sono della comunità (tauri-specta è elencato fra le integrazioni). Per averli generati usi ts-rs o tauri-specta, oppure li scrivi a mano e li proteggi con una guardia a runtime e un test sul JSON prodotto da Rust.
Devo derivare Serde per tutti i tipi dei comandi?
Per quelli che attraversano il confine sì: gli argomenti devono implementare Deserialize e i valori di ritorno Serialize, errori compresi. I tipi
che restano dentro Rust non ne hanno bisogno.
Come provo il frontend senza avviare l'app?
Con mockIPC del modulo @tauri-apps/api/mocks, che sostituisce il canale con una tua funzione. La documentazione ricorda di richiamare
clearMocks() dopo ogni test, per non portare lo stato finto in quello successivo.
Posso passare un file di molti megabyte a un comando?
È sconsigliato farlo come JSON. Per i dati binari la documentazione indica tauri::ipc::Response in uscita e il canale per i flussi. In ingresso
conviene far leggere il file direttamente a Rust, passando soltanto il percorso.
Che cosa sai fare adesso
Sai che cosa attraversa il confine fra Rust e il frontend e come si chiamano argomenti e campi. Sai proteggere un comando con una guardia a runtime, riconoscere un errore tipizzato fra i rifiuti del framework, e fissare la forma del JSON con un test Rust. Conosci i due strumenti per generare i tipi, il compromesso fra loro e la trappola degli interi a 64 bit. Il passo successivo è estendere l'app con funzioni native pronte: I plugin di Tauri 2.
Fonti
- Tauri 2: Calling Rust from the Frontend - argomenti in camelCase, errori con Result, comandi async e argomenti presi in prestito, Response e Channel, invoke_handler chiamato una sola volta, consultata il 2026-11-13
- Tauri 2: Inter-Process Communication - messaggi asincroni, protocollo JSON-RPC like e serializzazione in JSON, consultata il 2026-11-13
- Tauri 2: Mock Tauri APIs - mockIPC e clearMocks per provare il frontend, consultata il 2026-11-13
- Serde: Enum representations - la rappresentazione adiacente con tag e content, consultata il 2026-11-13
- tauri-specta 2.0.0-rc.28 su docs.rs - installazione con versioni bloccate, esportazione in debug, modalità di gestione degli errori, rifiuti del framework indistinguibili, consultata il 2026-11-13
- tauri-specta: README - tabella di compatibilità fra Tauri 1, Tauri 2 e le versioni di Specta, consultata il 2026-11-13
- ts-rs: README - derive TS, esportazione con cargo test, variabili d'ambiente, compatibilità con gli attributi Serde, bigint per i 64 bit, consultata il 2026-11-13
- crates.io: tauri-specta - versioni e date di pubblicazione lette dall'API del registro (ts-rs e taurpc inclusi) il 2026-11-13
- Tauri 2: Features and Recipes - taurpc e tauri-specta fra le risorse della comunità, consultata il 2026-11-13
La parte TypeScript è stata eseguita con Node 24.11 e @tauri-apps/api 2.12.4 (due esecuzioni identiche) e controllata con tsc in modalità strict; i listati Rust, la configurazione di ts-rs e di tauri-specta sono schemi da adattare, non compilati: sulla macchina di scrittura non ci sono Rust né Cargo.

Commenti
Caricamento commenti...
Accedi per commentare