7 min read

Type‑Safe Cross‑Runtime Contracts: Uniting TypeScript and Rust in Tauri with Zod, WASM, and Compile‑Time Guarantees

Learn how to share Zod‑derived schemas between TypeScript and Rust in a Tauri app, get compile‑time safety, and avoid runtime mismatches.
Type‑Safe Cross‑Runtime Contracts: Uniting TypeScript and Rust in Tauri with Zod, WASM, and Compile‑Time Guarantees

Introduction

Desktop applications built with Tauri combine a Rust backend (for native capabilities) with a JavaScript/TypeScript front‑end (usually a React, Vue or Svelte SPA).
The two runtimes speak to each other through WebAssembly (WASM) bindings or JSON‑based IPC.
A classic source of bugs is the contract between the two sides: a TypeScript interface that the Rust code expects, or a Rust struct that the UI sends.

If the contract drifts—say a field is renamed in Rust but the TypeScript type isn’t updated—the app may crash at runtime, produce subtle UI glitches, or silently discard data.

This article shows a pragmatic pattern for type‑safe cross‑runtime contracts:

Goal How we achieve it
Single source of truth for data shapes Write the schema once with Zod (a TypeScript‑first validation library).
Zero‑cost Rust bindings Generate Rust types and validation code from the Zod schema using a small code‑gen step.
Compile‑time guarantees The TypeScript compiler sees the exact shape; the Rust compiler checks the same shape.
Runtime validation Both sides validate incoming data against the same Zod schema (JS) or serde + zod-rs (Rust).
Fast IPC Serialize to MessagePack (or bincode) for minimal overhead.

The result is a contract‑first workflow that works for any Tauri app, regardless of the UI framework.


1. Why Zod?

Zod is a schema‑first library that:

  • Provides a type inference (z.infer<typeof schema>) yielding exact TypeScript types.
  • Can export JSON Schema (schema.toJSON()) – a format that other languages can consume.
  • Is runtime‑validated, meaning you can validate data coming from Rust before it reaches the UI.

Because Zod’s schema objects are plain JavaScript, they can be inspected by a small CLI tool that emits Rust code. This makes Zod the perfect “source of truth”.


2. Project Layout

my-tauri-app/
├─ src/
│  ├─ ts/
│  │  ├─ schemas/
│  │  │  └─ user.ts          # Zod schemas
│  │  └─ ipc.ts              # TS helpers that call Rust
│  └─ rust/
│     ├─ src/
│     │  ├─ generated/
│     │  │   └─ user.rs      # auto‑generated from Zod
│     │  └─ lib.rs
│     └─ build.rs              # runs the code‑gen step
└─ package.json

All contracts live under src/ts/schemas. The build.rs script runs before the Rust compilation and produces Rust types that mirror those schemas.


3. Defining a Zod Schema

// src/ts/schemas/user.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.coerce.date(),
  preferences: z.object({
    theme: z.enum(["light", "dark"]).default("light"),
    notifications: z.boolean().default(true),
  }),
});

export type User = z.infer<typeof UserSchema>;

Key points

  • z.coerce.date() accepts ISO strings from Rust and converts them to Date objects.
  • Defaults are defined once; both runtimes see the same fallback values.

4. Code Generation – From Zod to Rust

4.1 The generator script

Create a tiny Node script (gen-schema.js) that reads the Zod object, converts it to JSON Schema, then prints Rust structs with serde derives.

#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { z } = require("zod");

// Load the schema (adjust the path as needed)
const { UserSchema } = require("./src/ts/schemas/user");

function jsonSchema(zodSchema) {
  return zodSchema.toJSON();
}

// Very small mapping – for illustration only
function rustType(json) {
  const map = {
    string: "String",
    number: "f64",
    boolean: "bool",
    integer: "i64",
    "null": "Option<()>",
    "array": (items) => `Vec<${rustType(items)}>`,
    "object": (properties) => {
      const fields = Object.entries(properties).map(
        ([k, v]) => `${k}: ${rustType(v)},`
      );
      return `{ ${fields.join(" ")} }`;
    },
    "enum": (enumVals) => enumVals.map(v => `"${v}"`).join(" | "),
    "date": "String", // we keep ISO strings, later parsed in Rust
  };

  switch (json.type) {
    case "string":
      if (json.format === "date-time") return "String";
      return map.string;
    case "number":
    case "integer":
      return map[json.type];
    case "boolean":
      return map.boolean;
    case "array":
      return `Vec<${rustType(json.items)}>`;
    case "object":
      return `struct ${json.title} {\n${Object.entries(json.properties)
        .map(
          ([k, v]) => `    pub ${k}: ${rustType(v)},`
        )
        .join("\n")}\n}`;
    case "enum":
      return json.enum.map(v => `"${v}"`).join(" | ");
    default:
      throw new Error(`Unsupported type ${json.type}`);
  }
}

// Generate Rust code
function generate() {
  const json = jsonSchema(UserSchema);
  const structName = "User";
  const fields = Object.entries(json.properties)
    .map(
      ([k, v]) => `    pub ${k}: ${rustType(v)},`
    )
    .join("\n");

  const out = `use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ${structName} {
${fields}
}
`;

  const outPath = path.resolve(
    __dirname,
    "src/rust/src/generated/user.rs"
  );
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, out);
  console.log("✅ Generated Rust struct at", outPath);
}

generate();
Note – In production you’d use a mature generator like zod-to-ts + ts-json-schema-generator + jsonschema2rust. The example keeps the focus on the concept rather than tooling intricacies.

4.2 Hooking into Cargo

Add a build script (build.rs) that runs the generator before compiling Rust:

// src/rust/build.rs
use std::process::Command;

fn main() {
    // Run the Node generator
    let status = Command::new("node")
        .arg("../gen-schema.js")
        .status()
        .expect("Failed to run schema generator");

    if !status.success() {
        panic!("Schema generation failed");
    }

    // Re-run if any schema file changes
    println!("cargo:rerun-if-changed=../src/ts/schemas/");
}

Now cargo build automatically keeps the Rust struct in sync with the TypeScript schema.


5. Using the Shared Contract in Tauri

5.1 Sending data from TS → Rust

// src/ts/ipc.ts
import { invoke } from "@tauri-apps/api/tauri";
import { UserSchema, type User } from "./schemas/user";

export async function createUser(payload: unknown): Promise<User> {
  // Validate on the front‑end first
  const parsed = UserSchema.parse(payload);

  // Serialize to MessagePack (optional, can use JSON)
  const { encode } = await import("@msgpack/msgpack");
  const binary = encode(parsed);

  // Invoke Rust command, passing binary as a Uint8Array
  const result = await invoke<Uint8Array>("create_user", { data: binary });

  // Decode the response back to a User
  const decode = (await import("@msgpack/msgpack")).decode;
  return UserSchema.parse(decode(result));
}

5.2 Receiving data in Rust

// src/rust/src/lib.rs
use tauri::command;
use serde::{Deserialize, Serialize};
use rmp_serde::{Deserializer, Serializer};
use crate::generated::user::User;

#[command]
fn create_user(data: Vec<u8>) -> Result<Vec<u8>, String> {
    // 1️⃣ Deserialize incoming MessagePack payload
    let mut de = Deserializer::new(&data[..]);
    let mut user: User = Deserialize::deserialize(&mut de)
        .map_err(|e| format!("Invalid payload: {}", e))?;

    // 2️⃣ Business logic – e.g., store in SQLite
    // (omitted for brevity)

    // 3️⃣ Serialize a response (maybe with an assigned DB id)
    let mut buf = Vec::new();
    user.id = uuid::Uuid::new_v4().to_string(); // mutate struct
    user
        .serialize(&mut Serializer::new(&mut buf))
        .map_err(|e| format!("Serialization error: {}", e))?;

    Ok(buf)
}

Both sides share the same validation rules:

  • If a field is missing or malformed, the Zod parse call throws before any IPC.
  • In Rust, serde will reject mismatched types, returning a clear error that bubbles up to the UI.

6. Compile‑Time Guarantees

  • TypeScript: The User type is derived from UserSchema. Any change to the schema immediately updates the type used throughout the UI. The compiler will flag any place where the shape is used incorrectly.
  • Rust: The generated User struct is compiled. If the generator produces a field that the Rust code does not handle (e.g., you forget to import generated::user), the compiler errors out.

Thus, drift is impossible unless you bypass the generator entirely.


7. Advanced Tips

Situation Solution
Enum mapping – you need a Rust enum rather than a String. In the generator, detect z.enum([...]) and emit pub enum Role { Admin, Editor, Viewer } with #[serde(rename_all = "lowercase")].
Optional fields – you want Option<T> in Rust. Use z.optional(...) in the schema; the generator translates to Option<T>.
Versioned contracts – you need backward compatibility. Keep multiple schema files (e.g., user_v1.ts, user_v2.ts) and generate separate Rust modules. Use a version field in the payload to route to the right handler.
Testing the contract – you want a single source of test data. Store fixture JSON files that are validated by Zod in the test suite, then deserialize them in Rust unit tests using the same generated structs.
Performance – avoid double serialization. For large blobs, send a shared memory buffer using Tauri’s invoke with ArrayBuffer. The validation step can be done on the consumer side only.

8. Real‑World Example: A Note‑Taking App

Imagine a Tauri note‑taking app where each note can contain embedded images and a list of tags.

Zod schema (src/ts/schemas/note.ts)

import { z } from "zod";

export const TagSchema = z.object({
  id: z.string().uuid(),
  label: z.string(),
});

export const NoteSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1),
  body: z.string(),
  tags: z.array(TagSchema).default([]),
  attachments: z.array(z.object({
    name: z.string(),
    mime: z.string(),
    data: z.instanceof(Uint8Array), // binary payload
  })).default([]),
  createdAt: z.coerce.date(),
});

export type Note = z.infer<typeof NoteSchema>;

Running the generator produces note.rs with nested structs for Tag and Attachment. The UI can now:

await invoke<Uint8Array>("save_note", { data: encode(NoteSchema.parse(note)) });

And Rust can safely persist the note to a local SQLite DB, knowing that the shape matches exactly what the UI expects.


9. Limitations & Gotchas

  1. Generator complexity – The simple script above doesn’t cover every Zod feature (e.g., unions, refinements). For production, consider a dedicated library like zod-to-rust (community‑maintained) or write a more exhaustive translator.
  2. Binary size – Adding MessagePack and the generator adds a few kilobytes. If bundle size is critical, you can fall back to JSON; the safety guarantees remain.
  3. Debugging – When a contract violation occurs, you’ll see errors on both sides. Make sure you surface them to the user (or log them) rather than swallowing them.
  4. Tooling friction – The build.rs script must have Node available on the target machine. For CI pipelines, cache node_modules and ensure the generator runs on the same Node version used locally.

10. Summary

  • Write one Zod schema per domain object.
  • Generate Rust structs from that schema as part of the Cargo build.
  • Validate inbound data on both sides, using Zod in TS and serde (or zod-rs) in Rust.
  • Serialize with a compact binary format (MessagePack, bincode) to keep IPC fast.
  • Enjoy compile‑time guarantees: the TypeScript compiler and the Rust compiler will both fail if the contract diverges.

By treating the schema as the single source of truth, you eliminate a whole class of bugs that traditionally plague Tauri apps where the front‑end and back‑end evolve independently. The pattern scales from tiny utilities to large, multi‑window desktop suites, and it can be adapted to any WASM‑enabled runtime—not just Tauri.

Happy coding, and may your contracts stay forever in sync!