← ./articles

Type-Safe Tauri v2 IPC: Keep Rust and TypeScript Contracts from Drifting

Tauri v2 makes it straightforward to call Rust commands from a TypeScript frontend. The dangerous part is not the call itself. The dangerous part is assuming that Rust field names, JSON field names, TypeScript interfaces, optional arguments, and path strings all stay aligned by themselves.

They do not. A Tauri IPC boundary is a serialization boundary. Treat it like an API contract, even when both sides live in the same repository.

This article shows the contract checks that prevent the most common silent failures: camelCase and snake_case drift, missing optional parameters, large integer precision loss, path separator surprises, and error handling that disappears inside a component.

The symptom: TypeScript receives undefined even though Rust returned data

A common failure looks like this:

#[derive(Debug, serde::Serialize)]
pub struct AnalysisResult {
    pub source_file_count: usize,
    pub total_bytes: u64,
}
type AnalysisResult = {
  sourceFileCount: number;
  totalBytes: number;
};

The Rust command returns a valid object, but the UI reads result.sourceFileCount and gets undefined. Nothing in TypeScript complains, because the runtime JSON shape is not checked by the type annotation.

The JSON actually contains:

{
  "source_file_count": 12,
  "total_bytes": 94012
}

The frontend expected camelCase. Rust serialized snake_case. That is the bug.

Put serde naming on every IPC struct

For structs that cross the Tauri boundary, use an explicit serde naming rule:

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisResult {
    pub source_file_count: usize,
    pub total_bytes: u64,
}

Now Rust serializes:

{
  "sourceFileCount": 12,
  "totalBytes": 94012
}

The rule is simple: if a Rust struct is sent to TypeScript or received from TypeScript, it needs an explicit serialization shape. Do not rely on readers remembering which side uses which naming convention.

Match command parameters deliberately

Tauri command arguments are another place where naming can drift.

#[tauri::command]
pub fn calculate_crc(input: String, preset_name: Option<String>) -> Result<String, String> {
    Ok(format!("{input}:{preset_name:?}"))
}

Call it with the shape the command expects:

import { invoke } from "@tauri-apps/api/core";

await invoke<string>("calculate_crc", {
  input: "123456789",
  preset_name: null,
});

If you want camelCase arguments, prefer wrapping the input in a request struct and applying #[serde(rename_all = "camelCase")] to that struct:

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrcRequest {
    pub input: String,
    pub preset_name: Option<String>,
}

#[tauri::command]
pub fn calculate_crc(request: CrcRequest) -> Result<String, String> {
    Ok(request.input)
}
await invoke<string>("calculate_crc", {
  request: {
    input: "123456789",
    presetName: null,
  },
});

The important part is not whether you choose snake_case or camelCase. The important part is that the boundary has one documented rule.

Pass null, not undefined, for optional values

JavaScript undefined often means "field omitted." Rust Option<T> means "field present with null or value."

Use null when you mean "no value":

await invoke("load_project", {
  projectId: selectedProjectId ?? null,
});

Avoid:

await invoke("load_project", {
  projectId: selectedProjectId,
});

If selectedProjectId is undefined, the key may disappear from the serialized payload. That can make Rust deserialization fail or, worse, make it look as if a different command shape was intended.

Keep large numbers as strings

JavaScript numbers cannot exactly represent every u64 or u128. If Rust returns identifiers, memory addresses, checksums, timestamps, byte offsets, or bit masks that can exceed Number.MAX_SAFE_INTEGER, serialize them as strings:

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HashResult {
    pub hex: String,
    pub decimal: String,
}
type HashResult = {
  hex: string;
  decimal: string;
};

const value = BigInt(result.decimal);

This is especially useful for developer tools, binary tooling, embedded tooling, and file analyzers where numeric precision matters more than convenience.

Normalize file paths at the boundary

Windows paths contain backslashes. JSON strings escape backslashes. Browser code often expects forward slashes.

When returning paths to TypeScript, normalize the IPC-facing field:

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceFile {
    pub path: String,
}

impl SourceFile {
    pub fn from_path(path: impl AsRef<std::path::Path>) -> Self {
        Self {
            path: path.as_ref().to_string_lossy().replace('\\', "/"),
        }
    }
}

Keep PathBuf internally if you need it. Send a browser-friendly String across IPC.

Put transformations in one service layer

Components should not parse Rust response shapes directly. Put the mapping next to the invoke() call:

type RustPreset = {
  presetName: string;
  widthBits: number;
  checkHex: string;
};

type Preset = {
  name: string;
  width: number;
  check: bigint;
};

function mapPreset(raw: RustPreset): Preset {
  return {
    name: raw.presetName,
    width: raw.widthBits,
    check: BigInt(raw.checkHex),
  };
}

export async function listPresets(): Promise<Preset[]> {
  const raw = await invoke<RustPreset[]>("list_presets");
  return raw.map(mapPreset);
}

This gives you one place to test, one place to update when Rust changes, and one place to add runtime validation if the command becomes public or plugin-driven later.

Verification checklist

Before calling a Tauri IPC integration done, check these items:

  • Every IPC request and response struct has explicit serde naming.
  • Optional TypeScript values are sent as null, not undefined.
  • Values that may exceed JavaScript safe integer range are strings.
  • Windows paths are normalized before crossing into TypeScript.
  • Components import a service function instead of calling invoke() directly everywhere.
  • At least one test or smoke run compares the real Rust command output with the TypeScript mapping.

References

Summary

Tauri IPC bugs often look like frontend state bugs, but the root cause is usually a mismatched serialized shape. Make the Rust JSON shape explicit, map it once in TypeScript, and verify the boundary as an API. That is enough to avoid most silent undefined failures before they reach the UI.