Wasm + TypeScript 統合
WebAssemblyにおけるTypeScriptの重要性
RustをWasmにコンパイルすると、生のバウンダリは数値のみを扱います — i32、f64、そしてメモリオフセットです。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を参照する型(
HTMLCanvasElement、AudioContext) - コールバック関数のシグネチャ
- ジェネリックなラッパー型
- モジュール拡張
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の利用者は手動の作業ゼロでオートコンプリート、ドキュメント、コンパイル時の安全性を得られます。