Wasm + TypeScript Integration
Why TypeScript Matters for WebAssembly
When you compile Rust to Wasm, the raw boundary speaks only in numbers — i32, f64, and memory offsets. TypeScript provides a way to add a type-safe contract on the JavaScript side so that consumers of your Wasm module get autocompletion, compile-time error checking, and self-documenting APIs:
Without TypeScript Bindings With TypeScript Bindings
┌──────────────────────┐ ┌──────────────────────┐
│ JavaScript │ │ TypeScript │
│ │ │ │
│ wasm.greet(42) │ │ wasm.greet(42) │
│ // No error! │ │ // TS Error: │
│ // Crashes at │ │ // Argument of type │
│ // runtime │ │ // 'number' is not │
│ │ │ // assignable to │
│ │ │ // type 'string' │
└──────────────────────┘ └──────────────────────┘The type system catches bugs before they reach the Wasm boundary, where debugging is much harder.
How wasm-bindgen Generates TypeScript
When you run wasm-pack build, the toolchain produces several files:
pkg/
├── my_crate_bg.wasm ← compiled Wasm binary
├── my_crate_bg.wasm.d.ts ← types for raw Wasm exports
├── my_crate.js ← JS glue code
└── my_crate.d.ts ← TypeScript definitions ★The .d.ts file is generated directly from your #[wasm_bindgen] annotations:
// Rust side
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[wasm_bindgen]
pub struct Counter {
value: i32,
}
#[wasm_bindgen]
impl Counter {
#[wasm_bindgen(constructor)]
pub fn new(start: i32) -> Counter {
Counter { value: start }
}
pub fn increment(&mut self) {
self.value += 1;
}
pub fn get(&self) -> i32 {
self.value
}
}Generates:
// Auto-generated my_crate.d.ts
export function greet(name: string): string;
export class Counter {
free(): void;
constructor(start: number);
increment(): void;
get(): number;
}The tsify Crate for Complex Types
#[wasm_bindgen] works well for functions and opaque classes, but for plain data types you want TypeScript interfaces, not classes. That is where tsify comes in:
use tsify::Tsify;
use serde::{Serialize, Deserialize};
#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct User {
pub id: u32,
pub name: String,
pub email: Option<String>,
pub roles: Vec<String>,
}
#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub enum Status {
Loading,
Ready { data: Vec<f64> },
Error { message: String },
}This generates TypeScript interfaces and tagged unions instead of opaque classes:
export interface User {
id: number;
name: string;
email?: string;
roles: string[];
}
export type Status =
| { tag: "Loading" }
| { tag: "Ready"; data: number[] }
| { tag: "Error"; message: string };tsify vs wasm_bindgen for structs
| Feature | #[wasm_bindgen] struct |
#[tsify] struct |
|---|---|---|
| TS output | class with methods |
interface (plain obj) |
| Passed as | Heap pointer (opaque) | Serialized JSON/object |
| Destructuring | Not possible | Yes, plain object |
| Performance | Zero-copy (fast) | Serialization overhead |
| Complex nesting | Limited | Full support |
| Enum support | Flat C-style only | Tagged unions |
| Use when | Long-lived, has methods | Data transfer objects |
typescript_custom_section Attribute
For cases where auto-generation is not enough, you can inject custom TypeScript directly:
#[wasm_bindgen(typescript_custom_section)]
const TS_APPEND: &str = r#"
export interface WasmMemoryStats {
used_bytes: number;
total_bytes: number;
fragmentation_ratio: number;
}
export type EventCallback = (event: CustomEvent) => void;
export interface PluginApi {
init(config: Record<string, unknown>): Promise<void>;
process(data: Uint8Array): Promise<Uint8Array>;
destroy(): void;
}
"#;This is useful for:
- Types that reference browser APIs (
HTMLCanvasElement,AudioContext) - Callback function signatures
- Generic wrapper types
- Module augmentation
Rust to TypeScript Type Mapping
Rust Type TypeScript Type
───────────────────────────────── ────────────────────────
i8, i16, i32 number
u8, u16, u32 number
f32, f64 number
i64, u64 bigint
i128, u128 bigint
bool boolean
char string
String, &str string
Option<T> T | undefined
Vec<T> T[]
[T; N] T[] (fixed-size lost)
Box<[u8]> Uint8Array
HashMap<String, V> Map<string, V>
Result<T, E> T (Err → exception)
() void
JsValue any ← avoid this!Avoiding JsValue (the "any" escape hatch)
JsValue maps to TypeScript's any, which defeats the purpose of type safety. Prefer specific types:
// Bad: loses type information
#[wasm_bindgen]
pub fn process(input: JsValue) -> JsValue { /* ... */ }
// TS: process(input: any): any;
// Good: fully typed
#[wasm_bindgen]
pub fn process(input: &str) -> String { /* ... */ }
// TS: process(input: string): string;
// Good: complex types via tsify
#[wasm_bindgen]
pub fn process_user(user: User) -> ProcessResult { /* ... */ }
// TS: process_user(user: User): ProcessResult;Generic Wrapper Patterns
A common pattern is to create a typed wrapper around a Wasm module:
// wrapper.ts — hand-written, imports from generated .d.ts
import init, { greet, Counter, process_users } from './pkg/my_crate';
import type { User, Status, AppConfig } from './pkg/my_crate';
class WasmApi {
private ready: Promise<void>;
constructor() {
this.ready = init();
}
async greet(name: string): Promise<string> {
await this.ready;
return greet(name);
}
async processUsers(users: User[], config: AppConfig): Promise<number> {
await this.ready;
return process_users(users, config);
}
createCounter(start: number): Counter {
return new Counter(start);
}
}
export const wasmApi = new WasmApi();This pattern gives you:
- Lazy initialization (loads Wasm on first use)
- A single entry point for all Wasm functionality
- Framework-agnostic (works with React, Vue, Svelte, etc.)
wasm-pack Build Targets and TypeScript
wasm-pack build --target ...
┌──────────┬───────────────────────────────────────┐
│ Target │ Output │
├──────────┼───────────────────────────────────────┤
│ bundler │ ES modules (.d.ts included) │
│ │ For webpack, vite, rollup │
├──────────┼───────────────────────────────────────┤
│ web │ ES modules + manual init() │
│ │ .d.ts included, call init() yourself │
├──────────┼───────────────────────────────────────┤
│ nodejs │ CommonJS modules (.d.ts included) │
│ │ require() or import in Node │
├──────────┼───────────────────────────────────────┤
│ no-modules│ Global variable, no .d.ts │
│ │ Legacy, avoid for TypeScript │
└──────────┴───────────────────────────────────────┘For TypeScript projects, always use --target bundler or --target web.
Configuring tsconfig.json for Wasm
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"strict": true,
"types": ["@aspect/wasm-types"],
"paths": {
"my-wasm-pkg": ["./pkg/my_crate.d.ts"]
}
}
}Key settings:
"strict": true— catches type mismatches at the Wasm boundary"target": "ES2020"— needed forBigIntsupport (i64/u64)- Path aliases let you import the generated package cleanly
Summary
TypeScript integration transforms a raw Wasm module into a developer-friendly, type-safe library. Use #[wasm_bindgen] for functions and opaque types, tsify for data transfer objects and enums, and typescript_custom_section for edge cases. Always prefer specific Rust types over JsValue to preserve type information across the boundary. The generated .d.ts files give TypeScript consumers autocompletion, documentation, and compile-time safety with zero manual effort.