wasm-bindgen では Rust に組み込みの型だけでなくユーザーが定義した型を関数の引数・返り値に使うことができます。
例えば下記のように User 構造体を定義して、
// Rust#[wasm_bindgen(getter_with_clone)]pubstructUser { pub name: String, pub age: u32, } #[wasm_bindgen]impl User { #[wasm_bindgen(constructor)]pubfnnew(name: String, age: u32) -> User { User { name, age } } }
User を引数に取る関数を書くと、
// Rust#[wasm_bindgen]pubfngreet(user: &User) ->String { format!( "Hello, my name is {} and I am {} years old", user.name, user.age ) }
wasm-bindgen で下記のような .d.ts が生成されます。
// .d.tsexportclass User {free(): void; constructor(name:string, age:number); age: number; name: string; }exportfunctiongreet(user:User): string;
実際に JavaScript から呼び出してみると。
// JavaScriptconst user = new User("Smith", 25); user.constructor.name; // => 'User' greet(user); // => 'Hello, my name is Smith and I am 25 years old'
上手く動いているようですね。
class として型定義される不便
上記の例は上手く動いていますが、個人的には class として定義されていることに不便さを感じます。
class として定義されていることのデメリットはなんといっても JSON serializable ではないという点です。
例えば先ほどの user
は、
// JavaScriptconst user =newUser("Smith",25);// serializeJSON.stringify(user);// => '{"name":"Smith","age":25}'// deserializeJSON.parse('{"name":"Smith","age":25}');// => {name: 'Smith', age: 25}
このように JSON 形式でシリアライズし、再び Object に解釈できます。
が、ここでは「user は User 型である」という情報が失われています。
// JavaScriptconst user =newUser("Smith",25); user.constructor;// => Userconst serialized =JSON.stringify(user);const deserialized =JSON.parse(serialized); deserialized.constructor;// => Object
JSON 形式での シリアライズ/デシリアライズ は、ネットワーク越しに値を送信するとき localStorage に値を保存するときなどいろいろな場面で使われます。そのたびに型情報が失われてしまうのはとても不便です。
また外部 API などから取得した値を関数に渡すとき、わざわざクラスに詰め替える手間がかかって面倒なこともあります。
// JavaScriptconst{ name, age }=fetch(endpoint).then((res)=> res.json());// { name, age } をそのまま greet() に渡せないので User に詰め替えるconst user =newUser(name, age);greet(user);
面倒ですね。
interface として型定義されていると嬉しい
ここまで解説してきたデメリットは interface で型定義されていれば解消できます。
// TypeScriptinterfaceUser{name: string;age: number;}const user: User = {name: "Smith", age: 25}; const serialized: string = JSON.stringify(user); const deserialized: User = JSON.parse(serialized); // JSON から復元しても User インターフェースを満たす
外部 API から渡ってきた値でもプロパティ名と型が一致すればそのまま greet() 関数に渡せます。
// TypeScript const user = fetch(endpoint).then((res) => res.json()); greet(user);
楽です。
ただし、class でなくなったことによってカプセル化などの機能は失われます。
wasm-bindgen から (class ではなく) interface で .d.ts ファイルを吐かせるにはどうすればいいでしょうか。
公式ドキュメントを読んでもそのような方法は書かれていません。
そこで Tsify
Tsify というライブラリを使うと Rust の struct から TypeScript の interface を簡単に生成できます。
Tsifyは crates.io からインストールできます。長らく更新が止まっているので、Tsify の fork である Tsify-nextの方を使ったほうが良さそうです。cargo add tsify-next
でインストールしてから使います。
// Rustuseserde::{Deserialize, Serialize}; usetsify_next::Tsify; usewasm_bindgen::prelude::*; #[derive(Tsify, Serialize, Deserialize)]#[tsify(into_wasm_abi, from_wasm_abi)]pubstructUser { pub name: String, pub age: u32, } #[wasm_bindgen]pubfngreet(user: &User) ->String { format!( "Hello, my name is {} and I am {} years old", user.name, user.age ) }
#[derive(Tsify)]
属性をつけると対応する interface 定義が .d.ts ファイルに記載されるようになります。#[derive(Serialize, Deserialize)]
と #[tsify(into_wasm_abi, from_wasm_abi)]
は Rust の struct と JavaScript の object を相互に変換するために必要です。
生成される .d.ts ファイルは下記のようになります。
// .d.tsexportinterfaceUser{name: string;age: number;}exportfunctiongreet(user:User): string;
いいですね。
// TypeScriptconst user: User = {name: "Smith", age: 25}; greet(user); // => 'Hello, my name is Smith and I am 25 years old'
ちゃんと動きます。
enum の型付けも良い感じにできる
Tsify のもう一つの推しポイントが Rust の enum を良い感じに TypeScript の型で表現してくれる点です。
wasm-bindgen だけを使ったとき
まず Tsify を使わなかったとき、wasm-bindgen の標準の振る舞いを見ましょう。
// Rust#[wasm_bindgen]pubenumNumberEnum { Foo, Bar, Baz, } #[wasm_bindgen]pubfnprint_number_enum(number_enum: NumberEnum) ->String { /* 中略 */ }
NumberEnum を用意しました。 なぜ NumberEnum という名前なのでしょうか?この enum は内部的に (データの持ち方的に) 整数値を列挙しているのと等しいからです。
// Rust#[wasm_bindgen]pubenumNumberEnum { Foo =0, // ← このように書いたのと同じ意味になる Bar =1, Baz =2, }
.d.ts ファイルを生成するとこうなります。
// .d.tsexportenum NumberEnum { Foo = 0, Bar = 1, Baz = 2, }exportfunctionprint_number_enum(number_enum:NumberEnum): string;
TypeScript の enum が生成されました。ここでは深掘りしませんがあまり評判が良くないですね。
では Rust に戻ってもう一つの enum 表現を試します。
// Rust#[wasm_bindgen]pubenumStringEnum { Foo ="Foo", Bar ="Bar", Baz ="Baz", } #[wasm_bindgen]pubfnprint_string_enum(string_enum: StringEnum) ->String { /* 中略 */ }
.d.ts ファイルはこのようになります。
// TypeScriptexportfunctionprint_string_enum(string_enum:any): string;
なんと enum StringEnum
は消えてしまいました。そして print_string_enum() の引数は any になってしまっています。
これでは文字通り型情報を捨ててしまっています。
wasm-bindgen + Tsify を使ったとき
Tsidy を使ってみましょう。
// Rust#[derive(Tsify, Serialize, Deserialize)]#[tsify(into_wasm_abi, from_wasm_abi)]pubenumNumberEnum { Foo, Bar, Baz, } #[wasm_bindgen]pubfnprint_number_enum(number_enum: NumberEnum) ->String { /* 中略 */ }
.d.ts ファイルを生成すると。
// .d.tsexporttype NumberEnum ="Foo"|"Bar"|"Baz"; exportfunctionprint_number_enum(number_enum:NumberEnum): string;
スッキリ、そして今どきです。
まとめ
wasm-bindgen 標準の class を使った表現も JavaScript の抽象化の一つの方法と認めます。一方で現代的な TypeScript の表現をしたいなら、Tsify の生成する interface やユニオン型を使った型情報が好ましいですね。
使い方もシンプルで、属性をいくつか書き足すだけで動作するので導入は簡単です。
ぜひ Tsify を使って良い感じの型情報を生成してみてください。
私からは以上です。
おまけ
この記事を書くにあたって事前に書いた Rust コードとテストコード、生成した .d.ts ファイルの全てを下記のリポジトリに置いています。
Tsify を使わなかったときのコードがこのへんにあります。
- Rust: without_tsify/src/lib.rs
- .d.ts: without_tsify/pkg/index.d.ts
- 振る舞い確認のテストコー: without_tsify/tests/index.test.ts
Tsify を使ったコードがこのへんにあります。
- Rust: with_tsify/src/lib.rs
- .d.ts: with_tsify/pkg/index.d.ts
- 振る舞い確認のテストコー: with_tsify/tests/index.test.ts