When a Tauri IPC Return Type Changes, Update Every Caller Atomically
A Tauri command return type can change correctly on the Rust side and still break the app.
The dangerous migration is small: bool becomes String, or a simple value becomes a richer status. Rust compiles. TypeScript still compiles. The UI behaves backward because JavaScript truthiness turns a non-empty string into true.
Treat IPC return type changes as atomic migrations.
The symptom: expired still behaves like true
Imagine this command originally returned a boolean:
#[tauri::command]
fn check_pro_status() -> bool {
true
}
The frontend calls:
const isPro = await invoke<boolean>("check_pro_status");
if (isPro) {
unlockProFeatures();
}
Later, the backend needs more detail:
#[tauri::command]
fn check_pro_status() -> String {
"expired".to_string()
}
If a caller is missed, the TypeScript code may still say invoke<boolean>. At runtime, "expired" is truthy, so locked features can unlock.
Grep the command name, not the type
Before changing the Rust command, list every frontend caller:
rg "check_pro_status|checkProStatus|invoke<.*check_pro_status" src
Search for:
- raw
invoke("command") - typed
invoke<T>("command") - wrapper functions
- store initialization calls
- tests and mocks
Do not only search for boolean. Some callers may infer the type or hide it behind a service wrapper.
Replace booleans with explicit states
If the backend now returns multiple states, model that directly:
type ProStatus = "active" | "trial" | "expired" | "none" | "network_error";
const status = await invoke<ProStatus>("check_pro_status");
if (status === "active" || status === "trial") {
unlockProFeatures();
}
Avoid this:
if (status) {
unlockProFeatures();
}
Every allowed state should be handled intentionally.
Update mocks and tests in the same change
Test mocks are part of the IPC contract:
vi.mock("@tauri-apps/api/core", () => ({
invoke: async (command: string) => {
if (command === "check_pro_status") return "expired";
throw new Error(`unknown command ${command}`);
},
}));
Add a test that would have caught truthiness:
it("does not unlock pro features when status is expired", async () => {
const status: ProStatus = "expired";
expect(status === "active" || status === "trial").toBe(false);
});
This looks obvious, but it prevents future cleanup from reintroducing if (status).
Keep interdependent store fields atomic
If a Zustand store tracks status, isPro, and isInitialized, update them in one set() call:
set({
status,
isPro: status === "active" || status === "trial",
isInitialized: true,
});
Separate calls can create one-render intermediate states:
set({ status });
set({ isPro });
set({ isInitialized: true });
Those intermediate states can cause flicker, hidden panels, or temporary unlocked content.
Verification checklist
Before merging:
- grep every
invokecaller for the command name - update wrapper return types
- replace truthiness checks with explicit comparisons
- update mocks
- test every returned status
- inspect store updates for intermediate render states
- add a short comment if the command intentionally deviates from the usual wrapper pattern