無駄と文化

実用的ブログ

wasm-bindgen で Record<Keys, Type> 型を取り扱う

wasm-bindgen を使うと Rust の構造体から TypeScript の型情報を生成して wasm で受け渡しできるようになる。
やるべきことは #[wasm_bindgen] 属性をつけるだけで、wasm-bindgen がよしなにしてくれる。

// Rust

use wasm_bindgen::prelude::*;

#[wasm_bindgen(getter_with_clone)]
pub struct User {
    name: String,
    age: u32,
}

#[wasm_bindgen]
pub fn create_user(name: String, age: u32) -> User {
    User { name, age }
}

#[wasm_bindgen]
pub fn greet(user: &User) -> String {
    format!(
        "Hello, my name is {} and I am {} years old",
        user.name, user.age
    )
}

コンパイルするとこのような型情報が生成される。

// .d.ts

export class User {
  free(): void;
  age: number;
  name: string;
}

export function create_user(name: string, age: number): User;

export function greet(user: User): string;

 

Rust の構造体がほぼそのまま TypeScript から使えるようになるのはとても便利だ。

ところで Rust の構造体はあらかじめフィールドが定まっていなければいけないので、TypeScript の Record<Keys, Type> 型のように任意のキーを受け付けるように定義することはできない。
では Record<Keys, Type> 型を wasm-bindgen で扱いたいときはどうすればいいだろうか。

 

Tsify-next を使うとできる。

HashMap を使ってこのように書くと、

// Rust

use std::collections::HashMap;
use tsify_next:: declare;

#[declare]
pub type MyRecord = HashMap<String, u32>;

Record<string, number> として型情報を生成してくれる。

// .d.ts

export type MyRecord = Record<string, number>;

 

ところがこの MyRecord は FromWasmAbi や IntoWasmAbi を impl していないので引数として渡したり関数から返したりできない。

// Rust

pub fn do_something(record: MyRecord) { ... }
//                          ^^^^^^^^ the trait `wasm_bindgen::describe::WasmDescribe` is not 
//                                   implemented for `HashMap<std::string::String, u32>`

 

これでは不便なのでこうする。

// Rust

use std::collections::HashMap;
use tsify_next::Tsify;

#[derive(Tsify)]
pub struct MyRecord(HashMap<String, u32>);

Type Aliase をやめて1要素の Tuple Struct として定義する。

// .d.ts

export type MyRecord = Record<string, number>;

すると先ほどと同じく Record<string, number> として型情報が生成される。

 

Type Aliase として定義したときとの違いは、Tuple Struct として定義した MyRecord はユーザー定義型であるということだ。
ユーザー定義型であれば FromWasmAbi や IntoWasmAbi を impl できる。
しかも属性を付けるだけでマクロによってコード生成されるので追加でコードを書く必要はない。

// Rust

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tsify_next::Tsify;
use wasm_bindgen::prelude::*;

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct MyRecord(HashMap<String, u32>);

#[derive(Serialize, Deserialize)]#[tsify(into_wasm_abi, from_wasm_abi)] を追加した。

MyRecord を引数や返り値にする関数を書いてみる。

// Rust

#[wasm_bindgen]
pub fn create_my_record(keys: Vec<String>) -> MyRecord {
    let mut map = HashMap::new();
    for (i, s) in keys.into_iter().enumerate() {
        map.insert(s, i as u32);
    }
    MyRecord(map)
}

#[wasm_bindgen]
pub fn keys(my_record: MyRecord) -> Vec<String> {
    my_record.0.into_iter().map(|(k, _)| k).collect()
}

#[wasm_bindgen]
pub fn values(my_record: MyRecord) -> Vec<u32> {
    my_record.0.into_iter().map(|(_, v)| v).collect()
}

今度はちゃんとコンパイルできる。生成される型情報はこのようになる。

// .d.ts

export function create_my_record(keys: (string)[]): MyRecord;

export function keys(my_record: MyRecord): (string)[];

export function values(my_record: MyRecord): Uint32Array;

もちろん TypeScript から呼び出せばちゃんと動作する。

// TypeScript

const record = create_my_record(['hoge', 'fuga', 'piyo']);

console.log('keys: ', keys(record));
// => keys:  [ 'piyo', 'hoge', 'fuga' ]

console.log('values: ', values(record));
// => values:  Uint32Array(3) [ 1, 0, 2 ]

 

Tsify-next がとても便利。

 

 

それでは、enjoy! 👋

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 を使ったコードがこのへんにあります。

【wasm-bindgen】いろいろな型注釈による wasm の挙動の違いを調べよう

Rust で wasm を書くときの話題です。

 

Rust で書いたプログラムは wasm にコンパイルすることが可能です。さらに wasm-bindgen を使うと JavaScript から呼び出しやすいインターフェースを自動生成してくれます。

例えば下記のようなコードを書くと、

// Rust

#[wasm_bindgen]
pub fn add(x: JsValue, y: JsValue) -> JsValue {
    x + y
}

こんな感じの .d.ts ファイルを生成してくれます。

// .d.ts

export function add(x: any, y: any): any;

想像に難くない生成結果ですが、any だらけになっているのが惜しい感じがしますね。
もちろん Rust コードをいい感じに型注釈するともっといい .d.ts ファイルを生成させられます。

どんな型注釈によってどんな .d.ts ファイルが生成されるのか見てみましょう。

 

この記事を書くにあたって事前に書いた Rust コードとテストコード、生成した .d.ts ファイルの全てを下記のリポジトリに置いています。

github.com

 

3行まとめ

  • String, f64, Vec<T> などの型を使うと string, number, T; に解釈させられる
  • js_sys の型を使うと string, number, object, any; に解釈させられる
    (が、JavaScript から呼んでみると型検査されていないことが分かる)
  • Rust 側で型検査を忘れずにやろう

 

wasm-bindgen まわりの型付けいろいろ

JsValue

最初の例でも見たように JsValue 型は .d.ts 側で any に解釈されます。
そもそも JsValue が "JavaScript のあらゆる値" を表現するための構造体なので、JsValue で型注釈したものが any になるのは妥当ですね。

// Rust

#[wasm_bindgen]
pub fn add(x: JsValue, y: JsValue) -> JsValue {
    x + y
}
// .d.ts

export function add(x: any, y: any): any;

 

String, f64, Vec<T>

String, f64, Vec<JsValue> などで型注釈すると、.d.ts 側ではそれぞれ string, number, any[] に解釈されます。

// Rust

#[wasm_bindgen]
pub fn want_string(x: String) -> String {
    return x;
}

#[wasm_bindgen]
pub fn want_number(x: f64) -> f64 {
    return x;
}

#[wasm_bindgen]
pub fn want_array(x: Vec<JsValue>) -> Vec<JsValue> {
    console::log_1(&x.clone().into());
    return x;
}
// .d.ts

export function want_string(x: string): string;

export function want_number(x: number): number;

export function want_array(x: any[]): any[];

 

String などのお馴染みの型で注釈することにはいくつかの利点があります。
JsValue で型付けすると String として使う前に変換が必要です。一方で String として型付けしておけば Rust 関数で値を受け取った時点で String 型になっているので楽できます。

// Rust

#[wasm_bindgen]
pub fn want_any(x: JsValue) {
    if let Some(s) = x.as_string() {
        // x は JsValue 型, s は String 型
        do_something_with_string(s);
    }
}

#[wasm_bindgen]
pub fn want_string(x: String) {
    // x は String 型
    do_something_with_string(x);
}

もう一つ、JavaScript から want_string() 関数を呼ぶときに string 以外の値を渡すと例外を投げて落ちてくれます。

// JavaScript

want_string(42);
// => RuntimeError: unreachable

強い静的型付けの Rust 言語ユーザーからすると、想定していない値が渡されたときに即座に落ちてくれるのは安心ですね。

 

js_sys::{Array, JsString, Number, Object}

.d.ts 側で string や number に解釈させる方法は他にもあります。
js_sys の JsString, Number, Object, Array で型注釈する方法です。

// Rust

use js_sys::{Array, JsString, Number, Object};

#[wasm_bindgen]
pub fn maybe_want_string(x: JsString) -> JsString {
    return x;
}

#[wasm_bindgen]
pub fn maybe_want_number(x: Number) -> Number {
    return x;
}

#[wasm_bindgen]
pub fn maybe_want_object(x: Object) -> Object {
    return x;
}

#[wasm_bindgen]
pub fn maybe_want_array(x: Array) -> Array {
    return x;
}
// .d.ts

export function maybe_want_string(x: string): string;

export function maybe_want_number(x: number): number;

export function maybe_want_object(x: object): object;

export function maybe_want_array(x: Array<any>): Array<any>;

いい感じですね。
特に .d.ts 側で object に解釈させるのは js_sys::Object でないとできないと思います。

 

が、先ほど String, f64, Vec<T> で型注釈したときとは異なり、js_sys の型で注釈したときには想定していない値を渡しても例外を投げてくれません。

// JavaScript

maybe_want_string(42); // エラーは無い
// => 42

maybe_want_number(['hello', 42]); // エラーは無い
// => ['hello', 42]

maybe_want_array('hello'); // エラーは無い
// => 'hello'

例外が投げられるでもなく暗黙に型変換されるでもなく、渡した値がそのまま維持されているようですね。

 

js_sys の型で注釈するときは自分で検査が必要

js_sys::JsString などで注釈しても値の検査が行われないのは分かったとして。検査する方法が無いのかというともちろんあります。
js_sys::JsString に対して is_string(), as_string() などのメソッドが実装されているので、自分で型検査を書けばいいのです。

// Rust

#[wasm_bindgen]
pub fn really_want_string(x: JsString) -> Result<JsString, JsError> {
    if x.is_string() {
        console::log_1(&x.clone().into());
        Ok(x)
    } else {
        Err(JsError::new("Expected a string"))
    }
}

// または

#[wasm_bindgen]
pub fn really_want_string(x: JsString) -> Result<JsString, JsError> {
    match x.as_string() {
        Some(_) => {
            console::log_1(&x.clone().into());
            Ok(x)
        }
        None => Err(JsError::new("Expected a string")),
    }
}

x: JsString に対して 42 を渡してみましょう。

// JavaScript

really_want_string(42);
// => Error: Expected a string

このように。

 

遊んでみる

型検査されないことを利用して遊んでみましょう。2つの数を加算する関数を書きます。

// Rust

#[wasm_bindgen]
pub fn add(x: Number, y: Number) -> Number {
    x + y
}

コンパイル後の .d.ts ファイルでは number で型注釈されています。

// .d.ts

export function add(x: number, y: number): number;

とはいえこれは TypeScript の型定義ファイルです。JavaScript コードを書けば number 以外の値を渡してもコンパイラに怒られることはありません。

// JavaScript

add(3, 4);
// => 7

いいですね。

// JavaScript

add('foo', 'bar');
// => 'foobar'

なんと、+ 演算子を使って加算したつもりが文字列の結合ができてしまいました。

ということはこんなこともできますね。

// JavaScript

add(3, '4');
// => '34'

3 + '4''34'、なんてこった。

add() 関数を呼び出しているのは JavaScript 世界とはいえ、実装は Rust です。
Rust では 3 + '4' のような演算は許されません。いったい何が起こっているのでしょうか。

 

js_sys の演算を追って見る

js_sys のソースコードを見にいきましょう。

まず + 演算子は Add トレイト を実装することで任意の型に適用できるようになります。
今回の例では js_sys::Number 同士を + しているので impl<'a> Add<Number> for &'a Number が参照されます。実装を見てみましょう。

Number in js_sys - Rust

forward_js_binop!(impl Add, add for Number);

なんとシンプル。マクロを使ってたった1行で実装されていました。

lib.rs - source

macro_rules! forward_js_binop {
    (impl $imp:ident, $method:ident for $t:ty) => {
        impl $imp<&$t> for &$t {
            type Output = $t;

            #[inline]
            fn $method(self, other: &$t) -> Self::Output {
                $imp::$method(JsValue::as_ref(self), JsValue::as_ref(other)).unchecked_into()
            }
        }

        forward_deref_binop!(impl $imp, $method for $t);
    };
}

マクロ定義を雰囲気で読むと JsValue::as_ref(self) で &JsValue に変換してから演算しているようですね。

では JsValue 同士の + 演算の定義を知るために impl Add<&JsValue> for JsValue のソースを見にいくと。

JsValue in wasm_bindgen - Rust

impl Add for &JsValue {
    type Output = JsValue;

    /// Applies the binary `+` JS operator on two `JsValue`s.
    ///
    /// [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Addition)
    #[inline]
    fn add(self, rhs: Self) -> Self::Output {
        unsafe { JsValue::_new(__wbindgen_add(self.idx, rhs.idx)) }
    }
}

forward_deref_binop!(impl Add, add for JsValue);

__wbindgen_add を呼んでいるようです。
コメントを読むと JavaScript の + 演算子に相当するものみたいですね。

 

まとめると、
js_sys::Number 同士の + が Rust っぽくない挙動をしていたのは、ズバリ JavaScript の + を呼んでいる実装だからです。
number + number, string + string, number + string の結果が JavaScript で演算したときと一致していることからも確かめられます。

 

おまけ: 数として評価できる文字列について見ていく

JavaScript では '42' のような文字列が数として振る舞う場面があります。

// JavaScript

// 単項マイナス演算子
- '42';
// => -42

// 乗算演算子
'3' * '4';
// => 12

Rust で書いたいくつかのコードをコンパイルして "数として評価できる文字列" に対する振る舞いを見てみましょう。

 

// Rust

#[wasm_bindgen]
pub fn unary_negation(x: f64) -> f64 {
    -x
}
// JavaScript

unary_negation('42');
// => -42

Rust 側で f64 で型注釈した関数に JavaScript 側で '42' を渡すと、文字列をパースして 42 として渡してくれるようです。
これは便利。

 

// Rust

#[wasm_bindgen]
pub fn unary_negation(x: JsValue) -> f64 {
    match x.as_f64() {
        Some(x) => -x,
        None => Number::NAN,
    }
}
// JavaScript

unary_negation('42');
// => NaN

JsValue に対して .as_f64() で f64 への変換を試みています。が、文字列をパースしてくれないようですね。
None => Number::NAN によって NaN が返されました。

 

// Rust

#[wasm_bindgen]
pub fn unary_negation(x: Number) -> Number {
    -x
}
// JavaScript

unary_negation('42');
// => -42

js_sys::Number に対して直接 - を作用させた場合。予想通り JavaScript の単項マイナス演算子 - がそのまま呼ばれるために数として解釈されました。

 

まとめ

wasm-bindgen を使うと結構いろいろな書き方ができるんですが、それぞれに挙動が少しずつ違いました。

  • String, f64 などで型注釈すると割と柔軟に型変換してから渡してくれる
  • js_sys::JsString, js_sys::Number などで型注釈すると自前で型検査する必要があり、柔軟さは無い (余計な変換をしないので信頼できるとも言える)
  • js_sys::JsString, js_sys::Number などに対して演算子を適用すると JavaScript の演算子が呼ばれる

違いを把握しておかないと分かりづらい不具合を埋めてしまいそうですね。
特に wasm の内部で発生したエラーメッセージは読みづらくなりがちなので。

 

 

私からは以上です。