Sharing Types Between Rust WASM and TypeScript: A Complete Guide

11 min read, Thu, 26 Mar 2026

Computer Network

If you’ve ever built a project where Rust powers the backend logic via WebAssembly and TypeScript handles the frontend, you’ve probably hit the same wall: keeping your types in sync. You define a Product struct in Rust, serialize it to JSON, and then have to manually recreate the same shape in TypeScript — and every time one changes, the other breaks silently.

This tutorial walks you through a real-world setup that solves this problem end-to-end. We’ll use ts-rs to automatically generate TypeScript types from Rust structs and enums, wire it all up with a clean project structure, and automate the entire pipeline with a single command.

By the end you’ll have:


Project Structure Overview

Here’s the monorepo layout we’re working towards:

my-project/
├── libs/
│   ├── wasm-engine/          ← Rust WASM crate
│   │   ├── Cargo.toml
│   │   ├── src/
│   │   │   ├── lib.rs
│   │   │   ├── shared_types.rs
│   │   │   └── shared_types/
│   │   │       ├── product.rs
│   │   │       ├── order.rs
│   │   │       ├── user.rs
│   │   │       └── core.rs
│   │   └── tests/
│   │       └── generate_shared_types.rs
│   │
│   └── shared-types/         ← TypeScript types library
│       ├── package.json
│       ├── src/
│       │   └── types/        ← Auto-generated .ts files land here
│       └── scripts/
│           └── generate-index.mjs

└── scripts/
    └── generate-re-export-index.mjs

Part 1: Setting Up the Rust Crate

Cargo.toml

[package]
name = "wasm-engine"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"

[dev-dependencies]
ts-rs = { version = "9", features = ["serde-json-impl"] }

Two things to note here:


Part 2: Understanding Rust Modules

Before writing any types, let’s understand how Rust’s module system works — it trips up almost every beginner.

The Two Ways to Declare a Module

Old style — using mod.rs:

src/
└── shared_types/
    ├── mod.rs       ← declares the module
    └── product.rs

Modern style — using a sibling file:

src/
├── shared_types.rs  ← declares the module (preferred)
└── shared_types/
    └── product.rs

Both are equivalent. The modern style avoids having many files all named mod.rs which makes navigation confusing. We’ll use the modern style.

Key Rules

// In lib.rs — declare the top-level module
pub mod shared_types;

// In shared_types.rs — declare submodules
pub mod product;
pub mod order;

Module names cannot contain hyphens. mod shared-types is a compile error. Use underscores: mod shared_types. Your folder name must match: src/shared_types/.


Part 3: Defining Your Rust Types

src/lib.rs

pub mod shared_types;

That’s it. lib.rs stays thin — it just declares modules. As your project grows, you add more pub mod lines here, nothing else.

src/shared_types.rs

pub mod core;
pub mod product;
pub mod order;
pub mod user;

src/shared_types/core.rs

Core contains primitive building blocks used across other types:

use serde::{Deserialize, Serialize};
use ts_rs::TS;

/// A monetary value with currency
#[derive(TS, Debug, Serialize, Deserialize, Clone)]
pub struct Money {
    pub amount: f64,
    pub currency: String,
}

/// Geographic coordinates
#[derive(TS, Debug, Serialize, Deserialize, Clone)]
pub struct LatLng {
    pub lat: f64,
    pub lng: f64,
}

/// A reusable address type
#[derive(TS, Debug, Serialize, Deserialize, Clone)]
pub struct Address {
    pub line1: String,
    pub line2: Option<String>,
    pub city: String,
    pub country: String,
    pub postal_code: String,
}

/// Enum example — notice this is `enum`, not `struct`
#[derive(TS, Debug, Serialize, Deserialize, Clone)]
pub enum Currency {
    USD,
    EUR,
    GBP,
    JPY,
}

/// Enum with data attached to variants
#[derive(TS, Debug, Serialize, Deserialize, Clone)]
pub enum Dimension {
    Px(f32),
    Percent(f32),
    Rem(f32),
}

Common beginner mistake: Writing pub struct Currency { USD, EUR } — that’s invalid Rust. Variants with no fields or tuple data belong inside an enum, not a struct. A struct uses named fields with types: pub field_name: FieldType.

src/shared_types/product.rs

use crate::shared_types::core::Money;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(TS, Serialize, Deserialize, Debug, Clone)]
pub struct Product {
    pub id: String,
    pub name: String,
    pub description: Option<String>,
    pub price: Money,
    pub category: ProductCategory,
    pub inventory: u32,
    pub images: Vec<String>,
}

#[derive(TS, Serialize, Deserialize, Debug, Clone)]
pub enum ProductCategory {
    Electronics,
    Clothing,
    Food,
    Books,
    Other(String),
}

#[derive(TS, Serialize, Deserialize, Debug, Clone)]
pub struct ProductVariant {
    pub id: String,
    pub product_id: String,
    pub sku: String,
    pub attributes: std::collections::HashMap<String, String>,
    pub price_override: Option<Money>,
    pub stock: u32,
}

src/shared_types/order.rs

use crate::shared_types::core::{Address, Money};
use crate::shared_types::product::ProductVariant;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(TS, Serialize, Deserialize, Debug)]
pub struct Order {
    pub id: String,
    pub user_id: String,
    pub items: Vec<OrderItem>,
    pub status: OrderStatus,
    pub shipping_address: Address,
    pub subtotal: Money,
    pub tax: Money,
    pub total: Money,
}

#[derive(TS, Serialize, Deserialize, Debug)]
pub struct OrderItem {
    pub variant: ProductVariant,
    pub quantity: u32,
    pub unit_price: Money,
}

#[derive(TS, Serialize, Deserialize, Debug)]
pub enum OrderStatus {
    Pending,
    Confirmed,
    Shipped,
    Delivered,
    Cancelled(String), // reason
}

#[derive(TS, Serialize, Deserialize, Debug)]
pub struct OrderEvent {
    pub order_id: String,
    pub event_type: String,
    #[ts(type = "unknown")]
    pub payload: serde_json::Value, // Dynamic event data
}

Note the #[ts(type = "unknown")] on serde_json::Value fields. When you have a truly dynamic payload, telling ts-rs to emit unknown is the right call — it’s semantically accurate and avoids ts-rs trying to generate a nested serde_json/ type folder.

src/shared_types/user.rs

use crate::shared_types::core::Address;
use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(TS, Serialize, Deserialize, Debug)]
pub struct User {
    pub id: String,
    pub email: String,
    pub display_name: String,
    pub role: UserRole,
    pub addresses: Vec<Address>,
    pub preferences: UserPreferences,
}

#[derive(TS, Serialize, Deserialize, Debug)]
pub enum UserRole {
    Guest,
    Customer,
    Admin,
}

#[derive(TS, Serialize, Deserialize, Debug)]
pub struct UserPreferences {
    pub language: String,
    pub currency: String,
    pub email_notifications: bool,
    pub theme: String,
}

Part 4: Writing Integration Tests for Type Generation

Now for the part that actually generates your TypeScript files. We use Rust’s integration test system — files placed in a top-level tests/ directory are compiled as separate crates that consume your library from the outside.

Why integration tests and not unit tests? Integration tests live in tests/ and import your crate by name (use wasm_engine::...). This is the right place for “tooling” tests like type generation that aren’t testing internal logic. It also keeps lib.rs completely clean.

tests/generate_shared_types.rs

use ts_rs::TS;
use wasm_engine::shared_types::{
    core::{Address, Currency, Dimension, LatLng, Money},
    order::{Order, OrderEvent, OrderItem, OrderStatus},
    product::{Product, ProductCategory, ProductVariant},
    user::{User, UserPreferences, UserRole},
};

fn clean_output_dir(path: &std::path::Path) {
    if !path.exists() {
        return;
    }
    for entry in std::fs::read_dir(path).unwrap() {
        let entry = entry.unwrap();
        let path = entry.path();
        if path.is_dir() {
            // Remove subdirectories (e.g. serde_json/ that ts-rs might create)
            std::fs::remove_dir_all(&path).unwrap();
            println!("Deleted dir: {:?}", path);
        } else if path.extension().and_then(|e| e.to_str()) == Some("ts") {
            std::fs::remove_file(&path).unwrap();
            println!("Deleted: {:?}", path);
        }
    }
}

#[test]
fn generate_types() {
    // CARGO_MANIFEST_DIR is always the absolute path to the directory
    // containing Cargo.toml, resolved at compile time — no fragile relative paths
    let out = format!(
        "{}/../../shared-types/src/types",
        env!("CARGO_MANIFEST_DIR")
    );

    // Create the directory if it doesn't exist yet
    std::fs::create_dir_all(&out).unwrap();

    // Canonicalize gives us a clean absolute path with symlinks resolved
    let abs_out = std::fs::canonicalize(&out).unwrap();
    println!("Exporting types to: {:?}", abs_out);

    // Clean up stale files from previous runs
    clean_output_dir(&abs_out);

    let out_str = abs_out.to_str().unwrap();

    // core
    Money::export_all_to(out_str).unwrap();
    LatLng::export_all_to(out_str).unwrap();
    Address::export_all_to(out_str).unwrap();
    Currency::export_all_to(out_str).unwrap();
    Dimension::export_all_to(out_str).unwrap();

    // product
    Product::export_all_to(out_str).unwrap();
    ProductCategory::export_all_to(out_str).unwrap();
    ProductVariant::export_all_to(out_str).unwrap();

    // order
    Order::export_all_to(out_str).unwrap();
    OrderItem::export_all_to(out_str).unwrap();
    OrderStatus::export_all_to(out_str).unwrap();
    OrderEvent::export_all_to(out_str).unwrap();

    // user
    User::export_all_to(out_str).unwrap();
    UserRole::export_all_to(out_str).unwrap();
    UserPreferences::export_all_to(out_str).unwrap();
}

Why export_all_to() instead of export()?

We remove #[ts(export_to = "...")] from every struct entirely and rely solely on export_all_to() in the test. This avoids a subtle bug where ts-rs generates imports like import type { Money } from "../../../libs/wasm-engine/bindings/Money" — a path into the Rust crate’s internal bindings/ folder that doesn’t make sense for consumers.

Run the test with:

cargo test --test generate_shared_types -- --nocapture

Part 5: Automating the TypeScript Index File

ts-rs generates one .ts file per type. Your shared-types library needs a single index.ts that re-exports everything. Rather than maintaining this by hand, automate it.

scripts/generate-re-export-index.mjs

import { readdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const typesDir = join(__dirname, "../libs/shared-types/src/types");
const indexFile = join(typesDir, "index.ts");

const exports = readdirSync(typesDir)
  .filter((f) => f.endsWith(".ts") && f !== "index.ts")
  .sort()
  .map((f) => `export * from "./${f.replace(".ts", "")}";`)
  .join("\n");

const content = `// Auto-generated by generate-re-export-index.mjs. Do not edit manually.\n${exports}\n`;

writeFileSync(indexFile, content);
console.log("Generated index.ts:\n", exports);

libs/shared-types/package.json

{
  "name": "@my-project/shared-types",
  "version": "1.0.0",
  "type": "module",
  "main": "./src/types/index.ts",
  "scripts": {
    "generate:types": "cd ../wasm-engine && cargo test --test generate_shared_types -- --nocapture",
    "generate:index": "node ../../scripts/generate-re-export-index.mjs",
    "prettify:types": "prettier --write ./src/types/**/*.ts",
    "generate": "npm run generate:types && npm run generate:index && npm run prettify:types"
  },
  "devDependencies": {
    "prettier": "^3.0.0",
    "typescript": "^5.0.0"
  }
}

Now you can run:

npm run generate

And it will:

  1. Run cargo test to generate all .ts type files
  2. Scan the output directory and generate index.ts with all re-exports
  3. Run prettier to format everything cleanly

Part 6: What the Generated TypeScript Looks Like

After running npm run generate, your shared-types/src/types/ directory will contain files like:

Money.ts

// This file was generated by ts-rs. Do not edit this file manually.

export type Money = { amount: number, currency: string };

ProductCategory.ts

// This file was generated by ts-rs. Do not edit this file manually.

export type ProductCategory = "Electronics" | "Clothing" | "Food" | "Books" | { "Other": string };

Order.ts

// This file was generated by ts-rs. Do not edit this file manually.

import type { Address } from "./Address";
import type { Money } from "./Money";
import type { OrderItem } from "./OrderItem";
import type { OrderStatus } from "./OrderStatus";

export type Order = {
  id: string,
  user_id: string,
  items: Array<OrderItem>,
  status: OrderStatus,
  shipping_address: Address,
  subtotal: Money,
  tax: Money,
  total: Money,
};

index.ts (auto-generated)

// Auto-generated by generate-re-export-index.mjs. Do not edit manually.
export * from "./Address";
export * from "./Currency";
export * from "./Dimension";
export * from "./LatLng";
export * from "./Money";
export * from "./Order";
export * from "./OrderEvent";
export * from "./OrderItem";
export * from "./OrderStatus";
export * from "./Product";
export * from "./ProductCategory";
export * from "./ProductVariant";
export * from "./User";
export * from "./UserPreferences";
export * from "./UserRole";

In any TypeScript file in your monorepo you can now do:

import type { Order, Product, User } from "@my-project/shared-types";

Part 7: Scaling As Your Project Grows

The design intentionally makes growth painless. When you add a new Rust module:

  1. Create src/shared_types/inventory.rs with your types
  2. Add pub mod inventory; to src/shared_types.rs
  3. Add the exports to tests/generate_shared_types.rs:
use wasm_engine::shared_types::inventory::{StockLevel, Warehouse};

// in generate_types():
StockLevel::export_all_to(out_str).unwrap();
Warehouse::export_all_to(out_str).unwrap();
  1. Run npm run generate

lib.rs stays untouched. index.ts is auto-regenerated. Your TypeScript code gets the new types immediately.


Troubleshooting Common Issues

rust-analyzer crashing in devcontainer

If you’re running VS Code in a Podman or Docker devcontainer and rust-analyzer shows red or crashes with write EPIPE errors, the most common cause is insufficient container memory. rust-analyzer needs at least 2–3GB to index a project.

Check available memory:

free -h

If memory is low, increase it in Podman Desktop or Docker Desktop under Resources. Then lock it in by adding to .devcontainer/devcontainer.json:

{
  "runArgs": ["--memory=8g", "--memory-swap=8g"]
}

Also add a .vscode/settings.json to tell rust-analyzer where your Cargo.toml lives:

{
  "rust-analyzer.linkedProjects": [
    "libs/wasm-engine/Cargo.toml"
  ]
}

Module name has a hyphen

error: expected identifier
mod shared-types;

Rust identifiers cannot contain hyphens. Rename the folder and use underscores everywhere:

mod shared_types; // ✅
mod shared-types; // ❌

struct with variant syntax

error: expected `:`, found `(`
pub struct Status {
    Active(String),  // ❌

Tuple variants belong in an enum:

pub enum Status {
    Active(String),  // ✅
}

Imports pointing to bindings/ folder

If generated TypeScript imports look like:

import type { Money } from "../../../libs/wasm-engine/bindings/Money";

You have #[ts(export_to = "...")] attributes on your structs that conflict with the path passed to export_all_to(). Remove all #[ts(export_to = "...")] attributes from your Rust types — let export_all_to() in your test be the single source of truth.

serde_json/ subfolder appears in output

If a serde_json/JsonValue.ts file is generated, you have a serde_json::Value field without the #[ts(type = "unknown")] annotation. Add it to suppress the nested folder:

#[ts(type = "unknown")]
pub payload: serde_json::Value,

Summary

Here’s what we built:

The pattern scales naturally — each new Rust module gets one entry in shared_types.rs, one test file (or a few lines in the existing one), and the rest is automatic.