中級 getting-started

Wasm + TypeScript 統合

WebAssemblyにおけるTypeScriptの重要性

RustをWasmにコンパイルすると、生のバウンダリは数値のみを扱います — i32f64、そしてメモリオフセットです。TypeScriptはJavaScript側に型安全な契約を追加する手段を提供し、Wasmモジュールの利用者がオートコンプリート、コンパイル時のエラーチェック、自己文書化されたAPIを得られるようにします:

  TypeScriptバインディングなし         TypeScriptバインディングあり

  ┌──────────────────────┐           ┌──────────────────────┐
  │  JavaScript          │           │  TypeScript           │
  │                      │           │                      │
  │  wasm.greet(42)      │           │  wasm.greet(42)      │
  │  // エラーなし!      │           │  // TSエラー:        │// 実行時に         │           │  // 型 'number' を   │// クラッシュ       │           │  // 型 'string' に   │
  │                      │           │  // 割り当てることは  │
  │                      │           │  // できません        │
  └──────────────────────┘           └──────────────────────┘

型システムがバグをWasmバウンダリに到達する前にキャッチします。バウンダリでのデバッグは非常に困難です。

wasm-bindgenによるTypeScript生成の仕組み

wasm-pack buildを実行すると、ツールチェーンはいくつかのファイルを生成します:

  pkg/
  ├── my_crate_bg.wasm          ← コンパイル済みWasmバイナリ
  ├── my_crate_bg.wasm.d.ts     ← 生のWasmエクスポートの型
  ├── my_crate.js               ← JSグルーコード
  └── my_crate.d.ts             ← TypeScript定義 ★

.d.tsファイルは#[wasm_bindgen]アノテーションから直接生成されます:

// Rust側
#[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
    }
}

生成結果:

// 自動生成された my_crate.d.ts
export function greet(name: string): string;

export class Counter {
  free(): void;
  constructor(start: number);
  increment(): void;
  get(): number;
}

複雑な型のためのtsifyクレート

#[wasm_bindgen]は関数と不透明なクラスに対してうまく動作しますが、プレーンなデータ型にはクラスではなくTypeScriptインターフェースが必要です。そこでtsifyの出番です:

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 },
}

これにより、不透明なクラスの代わりにTypeScriptのインターフェースタグ付きユニオンが生成されます:

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

機能 #[wasm_bindgen] struct #[tsify] struct
TS出力 メソッド付きclass interface(プレーンobj)
渡し方 ヒープポインタ(不透明) シリアライズされたJSON/obj
分割代入 不可 可能(プレーンオブジェクト)
パフォーマンス ゼロコピー(高速) シリアライゼーションのオーバーヘッド
複雑なネスト 限定的 完全サポート
列挙型サポート フラットなCスタイルのみ タグ付きユニオン
使用場面 長寿命、メソッドあり データ転送オブジェクト

typescript_custom_section属性

自動生成では不十分な場合、カスタムTypeScriptを直接注入できます:

#[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;
}
"#;

これは以下の場合に便利です:

  • ブラウザAPIを参照する型(HTMLCanvasElementAudioContext
  • コールバック関数のシグネチャ
  • ジェネリックなラッパー型
  • モジュール拡張

RustからTypeScriptへの型マッピング

  Rust型                             TypeScript型
  ─────────────────────────────────  ────────────────────────
  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[](固定サイズ情報は失われる)
  Box<[u8]>                          Uint8Array
  HashMap<String, V>                 Map<string, V>
  Result<T, E>                       T(Errは例外に変換)
  ()                                 void
  JsValue                            any ← これは避けましょう!

JsValue("any"エスケープハッチ)の回避

JsValueはTypeScriptのanyにマッピングされ、型安全性の意味がなくなります。具体的な型を使いましょう:

// 悪い例: 型情報が失われる
#[wasm_bindgen]
pub fn process(input: JsValue) -> JsValue { /* ... */ }
// TS: process(input: any): any;

// 良い例: 完全に型付けされている
#[wasm_bindgen]
pub fn process(input: &str) -> String { /* ... */ }
// TS: process(input: string): string;

// 良い例: tsifyを使った複雑な型
#[wasm_bindgen]
pub fn process_user(user: User) -> ProcessResult { /* ... */ }
// TS: process_user(user: User): ProcessResult;

ジェネリックラッパーパターン

一般的なパターンとして、Wasmモジュールに型付きラッパーを作成します:

// wrapper.ts — 手書き、生成された.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();

このパターンにより以下が得られます:

  • 遅延初期化(初回使用時にWasmをロード)
  • すべてのWasm機能への単一エントリポイント
  • フレームワーク非依存(React、Vue、Svelteなどで動作)

wasm-packビルドターゲットとTypeScript

  wasm-pack build --target ...

  ┌──────────┬───────────────────────────────────────┐
  │ ターゲット │ 出力                                  │
  ├──────────┼───────────────────────────────────────┤
  │ bundler  │ ESモジュール(.d.ts含む)               │
  │          │ webpack、vite、rollup向け               │
  ├──────────┼───────────────────────────────────────┤
  │ web      │ ESモジュール + 手動init()               │
  │          │ .d.ts含む、自分でinit()を呼ぶ           │
  ├──────────┼───────────────────────────────────────┤
  │ nodejs   │ CommonJSモジュール(.d.ts含む)         │
  │          │ require()またはimportでNode利用          │
  ├──────────┼───────────────────────────────────────┤
  │ no-modules│ グローバル変数、.d.tsなし              │
  │          │ レガシー、TypeScriptでは避ける           │
  └──────────┴───────────────────────────────────────┘

TypeScriptプロジェクトでは、常に--target bundlerまたは--target webを使用してください。

Wasm用tsconfig.jsonの設定

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["@aspect/wasm-types"],
    "paths": {
      "my-wasm-pkg": ["./pkg/my_crate.d.ts"]
    }
  }
}

重要な設定:

  • "strict": true — Wasmバウンダリでの型の不一致を検出
  • "target": "ES2020"BigIntサポート(i64/u64)に必要
  • パスエイリアスにより、生成されたパッケージをクリーンにインポート可能

まとめ

TypeScript統合は、生のWasmモジュールを開発者フレンドリーで型安全なライブラリに変換します。関数と不透明な型には#[wasm_bindgen]を、データ転送オブジェクトと列挙型にはtsifyを、エッジケースにはtypescript_custom_sectionを使用しましょう。バウンダリ全体で型情報を保持するために、常にJsValueよりも具体的なRust型を優先してください。生成された.d.tsファイルにより、TypeScriptの利用者は手動の作業ゼロでオートコンプリート、ドキュメント、コンパイル時の安全性を得られます。

試してみる