← ./articles

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 invoke caller 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

References