Sharing Types Between Rust WASM and TypeScript: A Complete Guide

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:
- A Rust WASM crate that exports TypeScript types automatically
- A shared TypeScript library that consumes those types
- A single
npm run generatecommand that keeps everything in sync
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:
crate-type = ["cdylib", "rlib"]—cdylibproduces the.wasmbinary for the browser.rliblets the crate be used as a normal Rust library, which is required for integration tests.ts-rsis a dev-dependency because type generation only happens during testing, not in the final WASM build. Theserde-json-implfeature enablesserde_json::Valueto work with ts-rs.
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 anenum, not astruct. Astructuses 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 keepslib.rscompletely 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()?
export()— exports only the type it’s called on, using the path from#[ts(export_to = "...")]export_all_to(dir)— exports the type and all its dependencies into the given directory, with correct relative imports between generated files
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:
- Run
cargo testto generate all.tstype files - Scan the output directory and generate
index.tswith all re-exports - 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:
- Create
src/shared_types/inventory.rswith your types - Add
pub mod inventory;tosrc/shared_types.rs - 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();
- 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:
- Clean module structure using the modern
shared_types.rs+shared_types/pattern instead ofmod.rs - ts-rs to derive TypeScript types directly from Rust structs and enums with zero manual work
- Integration tests in
tests/generate_shared_types.rsthat clean the output directory before regenerating, preventing stale types from accumulating CARGO_MANIFEST_DIRinstead of fragile relative paths, so the setup works from any working directory- A Node.js script that auto-generates
index.tsso consumers get a single clean import point - A single
npm run generatecommand that orchestrates the entire pipeline
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.