wasm-bindgen では Rust に組み込みの型だけでなくユーザーが定義した型を関数の引数・返り値に使うことができます。
例えば下記のように User 構造体を定義して、
// Rust #[wasm_bindgen(getter_with_clone)] pub struct User { pub name: String, pub age: u32, } #[wasm_bindgen] impl User { #[wasm_bindgen(constructor)] pub fn new(name: String, age: u32) -> User { User { name, age } } }
User を引数に取る関数を書くと、
// Rust #[wasm_bindgen] pub fn greet(user: &User) -> String { format!( "Hello, my name is {} and I am {} years old", user.name, user.age ) }
wasm-bindgen で下記のような .d.ts が生成されます。
// .d.ts export class User { free(): void; constructor(name: string, age: number); age: number; name: string; } export function greet(user: User): string;
実際に JavaScript から呼び出してみると。
// JavaScript const 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
は、
// JavaScript const user = new User("Smith", 25); // serialize JSON.stringify(user); // => '{"name":"Smith","age":25}' // deserialize JSON.parse('{"name":"Smith","age":25}'); // => {name: 'Smith', age: 25}
このように JSON 形式でシリアライズし、再び Object に解釈できます。
が、ここでは「user は User 型である」という情報が失われています。
// JavaScript const user = new User("Smith", 25); user.constructor; // => User const serialized = JSON.stringify(user); const deserialized = JSON.parse(serialized); deserialized.constructor; // => Object
JSON 形式での シリアライズ/デシリアライズ は、ネットワーク越しに値を送信するとき localStorage に値を保存するときなどいろいろな場面で使われます。そのたびに型情報が失われてしまうのはとても不便です。
また外部 API などから取得した値を関数に渡すとき、わざわざクラスに詰め替える手間がかかって面倒なこともあります。
// JavaScript const { name, age } = fetch(endpoint).then((res) => res.json()); // { name, age } をそのまま greet() に渡せないので User に詰め替える const user = new User(name, age); greet(user);
面倒ですね。
interface として型定義されていると嬉しい
ここまで解説してきたデメリットは interface で型定義されていれば解消できます。
// TypeScript interface User { 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
でインストールしてから使います。
// Rust use serde::{Deserialize, Serialize}; use tsify_next::Tsify; use wasm_bindgen::prelude::*; #[derive(Tsify, Serialize, Deserialize)] #[tsify(into_wasm_abi, from_wasm_abi)] pub struct User { pub name: String, pub age: u32, } #[wasm_bindgen] pub fn greet(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.ts export interface User { name: string; age: number; } export function greet(user: User): string;
いいですね。
// TypeScript const 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] pub enum NumberEnum { Foo, Bar, Baz, } #[wasm_bindgen] pub fn print_number_enum(number_enum: NumberEnum) -> String { /* 中略 */ }
NumberEnum を用意しました。 なぜ NumberEnum という名前なのでしょうか?この enum は内部的に (データの持ち方的に) 整数値を列挙しているのと等しいからです。
// Rust #[wasm_bindgen] pub enum NumberEnum { Foo = 0, // ← このように書いたのと同じ意味になる Bar = 1, Baz = 2, }
.d.ts ファイルを生成するとこうなります。
// .d.ts export enum NumberEnum { Foo = 0, Bar = 1, Baz = 2, } export function print_number_enum(number_enum: NumberEnum): string;
TypeScript の enum が生成されました。ここでは深掘りしませんがあまり評判が良くないですね。
では Rust に戻ってもう一つの enum 表現を試します。
// Rust #[wasm_bindgen] pub enum StringEnum { Foo = "Foo", Bar = "Bar", Baz = "Baz", } #[wasm_bindgen] pub fn print_string_enum(string_enum: StringEnum) -> String { /* 中略 */ }
.d.ts ファイルはこのようになります。
// TypeScript export function print_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)] pub enum NumberEnum { Foo, Bar, Baz, } #[wasm_bindgen] pub fn print_number_enum(number_enum: NumberEnum) -> String { /* 中略 */ }
.d.ts ファイルを生成すると。
// .d.ts export type NumberEnum = "Foo" | "Bar" | "Baz"; export function print_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