無駄と文化

実用的ブログ

wasm-bindgen の型情報を Tsify でもっと良い感じにする

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 ファイルの全てを下記のリポジトリに置いています。

github.com

Tsify を使わなかったときのコードがこのへんにあります。

Tsify を使ったコードがこのへんにあります。