Salta al contenuto principale

02 - Rust frontend integration: tipi shared

Come tenere allineati i tipi fra Rust e TypeScript in Tauri 2: JSON, guardie a runtime, errori tipizzati e generazione dei binding con ts-rs o tauri-specta.

Cosa ne pensi di questo articolo?

02 - Rust frontend integration: tipi shared
Tauri Rust DesktopFoto:Godfrey AtimaPexels

In Breve

Come tenere allineati i tipi fra Rust e TypeScript in Tauri 2: JSON, guardie a runtime, errori tipizzati e generazione dei binding con ts-rs o tauri-specta.

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.

Un comando con tipi di dato e un errore tipizzato (schema da adattare)
// 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.

Specchio dei tipi e guardia a runtime
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.

Prova della guardia con mockIPC, eseguita con Node 24
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.

Test che fissa la forma del JSON (schema da adattare)
// 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.

ts-rs: la derive che esporta il tipo
// 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.

Configurazione di ts-rs nel progetto
# 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.

tauri-specta: tipi e comandi generati (schema da adattare)
// 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_case quando il comando si aspetta camelCase.
  • Segnare pub un comando dentro lib.rs, che produce l'errore E0255.
  • Chiamare invoke_handler più volte: conta solo l'ultima chiamata.
  • Usare &str o State come argomento di un comando async.
  • Dichiarare string | null su un campo con skip_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

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti