← Back to Lessons Lesson 39 of 48
Intermediate getting-started

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 for BigInt support (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.

Try It