無駄と文化

実用的ブログ

wasm-bindgen で「この型が欲しいときはこう書く」集

この記事は Rust Advent Calendar 2024 の 8日目の記事です。

 

wasm-bindgen で wasm に型を付ける

Rust は wasm にコンパイルできるよう意図された言語でもあります。Rust コードを wasm にコンパイルしてしまえば、ブラウザをはじめとした wasm ランタイムで実行できてとても便利です。

wasm-bindgen を使うと JavaScript から呼び出しやすいインターフェースを自動で生成できます。さらに TypeScript の型定義ファイル (.d.ts ファイル) も生成されます。
しかし引数や返り値に JsValue を使ってしまうと .d.ts 側では any 型になってしまいます。これではせっかくの型定義が台無しです。
この記事では .d.ts 側で上手く型付けされる Rust コードを書くことを目標とします。「.d.ts 側でこの型が欲しいなら Rust コードではこう書く」というプラクティスをひたすら列挙していきます。

 

ここで紹介している例は下記のリポジトリにまとめています。
コンパイルした wasm を TypeScript から呼び出すテストコードもあるので挙動の理解に役立つはずです。

github.com

 

事前準備

下記のクレートを使用します。

事前に Cargo.toml に書き加えておきましょう。

[dependencies]
wasm-bindgen = "0.2.84"
tsify-next = "0.5.4"
serde = "1.0.215"

 

まとめ

は改行を表す

TypeScript の型 Rust でこう書く 備考
◾️ number u8, u16, u32, i8, i16, i32 string から暗黙に型変換される
◾️ number f32, f64 string から暗黙に型変換される
◾️ bigint u64, u128, i64, i128 string から暗黙に型変換される
◾️ string String, &str
◾️ boolean bool number から暗黙に型変換される
◾️ Symbol - 無し
◾️ null #[derive(Tsify)]↵ struct Null;
◾️ undefined - 無し
◾️ T[] Vec<T> T は具体型でなければいけない
◾️ Uint8Array Vec<u8>
◾️ Int8Array Vec<i8>
◾️ Uint16Array Vec<u16>
◾️ Int16Array Vec<i16>
◾️ Uint32Array Vec<u32>
◾️ Int32Array Vec<i32>
◾️ BigUint64Array Vec<u64>, Vec<u128>
◾️ BigInt64Array Vec<i64>, Vec<i128>
◾️ Float32Array Vec<f32>
◾️ Float64Array Vec<f64>
◾️ Function js_sys::Function 引数や返り値の型を明示できない
◾️ [T1, T2, T3] #[derive(Tsify)]↵ struct MyTuple(T1, T2, T3); T1 などは具体型でなければいけない
◾️ "Foo" | "Bar" | "Baz" #[derive(Tsify)]↵ enum MyUnion { Foo, Bar, Baz }
◾️ T1 | T2 | ... #[derive(Tsify)]↵ enum MyUnion { T1, T2, ... } T1 などは具体型でなければいけない
◾️ interface { ... } #[derive(Tsify)]↵ struct MyInterface { ... }
◾️ { prop?: T; } #[derive(Tsify)]↵ struct MyInterface { #[tsify(optional)] prop: Option<T> } T は具体型でなければいけない
◾️ { prop: T | null; } #[derive(Tsify)]↵ struct MyInterface { prop: Option<T> } T は具体型でなければいけない
◾️ Record<K, T> #[derive(Tsify)]↵ struct MyRecord(HashMap<K, T>); K, T は具体型でなければいけない
◾️ Map<K, T> #[derive(Tsify)]↵ struct MyMap(HashMap<K, T>); features = ["js"] が必要
K, T は具体型でなければいけない
Record<K, T> から暗黙に型変換される
◾️ function f(x?: T) fn f(x: Option<T>) { ... } T は具体型でなければいけない
◾️ namespace { ... } #[derive(Tsify)]↵ #[tsify(namespace)]↵ enum MyNamespace { ... }

これ以降、具体的に解説していきます。

 

プリミティブ型

JavaScript には7種類のプリミティブ型があります。
数値 number, 長整数 bigint, 文字列 string, 論理値 boolean, シンボル Symbol, null, undefined です。

数値 number

number が欲しいときには Rust の u8, u16, u32, i8, i16, i32, f32, f64 を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_number_into_rust_u32(n: u32) -> u32 {
    console::log_1(&format!("value: {:?}", n).into());
    return n;
}
// .d.ts

export function from_ts_number_into_rust_u32(n: number): number;

 

.d.ts ではどれも number 型になってしまいますが、もちろん Rust に受け渡したときに保持できる値の範囲が変わってきます。
例えば i8 で型付けすると保持できるのは -128 ~ 127 です。

では i8 の最小値・最大値を超える値を JavaScript から渡すとどうなるでしょうか。
なんと「オーバーフロー・アンダーフローして範囲内に収める」という挙動になります。

// JavaScript

from_ts_number_into_rust_i8(128); // i8 の最大値 127 を超えている!
// => log: "value: -128"

型検査でもエラーにならず実行時の例外にもならないので、この挙動には驚くかも知れません。

 

また .d.ts で number で型付けされるものの、「数値として解釈可能な文字列」を渡すことが可能です。
内部で数値としてパースされます。

// JavaScript

from_ts_number_into_rust_i8("42"); // 文字列を渡す
// => log: "value: 42"

 

長整数 bigint

number が欲しいときには Rust の u64, u128, i64, i128 を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_bigint_into_rust_u64(n: u64) -> u64 {
    console::log_1(&format!("value: {:?}", n).into());
    return n;
}
// .d.ts

export function from_ts_bigint_into_rust_u64(n: bigint): bigint;

 

もちろん bigint を渡すことが可能です。例えば 9_007_199_254_740_991n など。
一方で number を渡そうとすると例外が投げられます。

// JavaScript

from_ts_bigint_into_rust_u64(9_007_199_254_740_991n);
// => log: "value: 9007199254740991"

from_ts_bigint_into_rust_u64(42); // number は渡せない
// => 例外が投げられる

 

数値に解釈可能な文字列を渡せる仕様も健在です。

// JavaScript

from_ts_bigint_into_rust_u64("9007199254740991");
// => log: "value: 9007199254740991"

 

文字列 string

string が欲しいときには Rust の String または &str を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_string_into_rust_string(s: String) -> String  {
    console::log_1(&format!("value: {:?}", s).into());
    return s;
}
// .d.ts

export function from_ts_string_into_rust_string(s: string): string;

 

string 以外の値を渡すと例外が投げられます。暗黙に数値が文字列化されるような挙動はありません。

// JavaScript

from_ts_string_into_rust_string(42);
// => 例外が投げられる

 

論理値 boolean

boolean が欲しいときには Rust の bool を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_boolean_into_rust_bool(b: bool) -> bool {
    console::log_1(&format!("value: {:?}", b).into());
    return b;
}
// .d.ts

export function from_ts_boolean_into_rust_bool(b: boolean): boolean;

 

JavaScript から呼び出すときには boolean の他に number も受け入れてくれるようです。
0 を渡せば Rust 側で false と解釈されます。0 以外の値は true と解釈されます。

// JavaScript

from_ts_boolean_into_rust_bool(true);
// => log: "value: true"

from_ts_boolean_into_rust_bool(false);
// => log: "value: false"

from_ts_boolean_into_rust_bool(1);
// => log: "value: true"

from_ts_boolean_into_rust_bool(0);
// => log: "value: false"

 

シンボル Symbol

Symbol を受け渡しする方法はありません。

 

null

関数の引数を null で型付けしたい場面は無い気がしますが、そのように書く方法はあります。
Rust のユニット構造体 (unit struct) から型を生成すると null になります。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Null;

#[wasm_bindgen]
pub fn from_ts_null_into_rust_unit_struct(_null: Null) -> Null {
    console::log_1(&format!("value: {:?}", _null).into());
    return _null;
}
// .d.ts

export type Null = null;

export function from_ts_null_into_rust_unit_struct(_null: Null): Null;

null のエイリアスとして Null が定義されました。
Null を引数や返り値に使うことで null で型付けできます。

 

null が登場するもっと実用的な例は string | null などの Nullable な型でしょう。それについてはこの記事の後の方で解説します。

 

undefined

引数や返り値を undefined で型付けする方法はありません。

 

実用的な例として省略可能な引数を表現するときに undefined が登場します。それについてはこの記事の後の方で解説します。

 

配列型・TypedArray

引数や返り値に配列型を指定することで複数の値をまとめて受け取ったり返したりできます。

T[]

T[] が欲しいときには Rust の Vec<T> を使います。
ただし T は具体型でないとダメで、型パラメーターを使って fn f<T>(x: Vec<T>) { ... } のようには書けません。

// Rust

#[wasm_bindgen]
pub fn from_ts_array_into_rust_vec(strings: Vec<String>) -> Vec<String> {
    console::log_1(&format!("value: {:?}", strings).into());
    return strings;
}
// .d.ts

export function from_ts_array_into_rust_vec(strings: (string)[]): (string)[];

 

Uint8Array

Uint8Array が欲しいときには Rust の Vec<u8> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_uint8array_into_rust_vec(numbers: Vec<u8>) -> Vec<u8> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_uint8array_into_rust_vec(numbers: Uint8Array): Uint8Array;

 

Int8Array

Int8Array が欲しいときには Rust の Vec<i8> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_int8array_into_rust_vec(numbers: Vec<i8>) -> Vec<i8> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_int8array_into_rust_vec(numbers: Int8Array): Int8Array;

 

Uint16Array

Uint16Array が欲しいときには Rust の Vec<u16> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_uint16array_into_rust_vec(numbers: Vec<u16>) -> Vec<u16> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_uint16array_into_rust_vec(numbers: Uint16Array): Uint16Array;

 

Int16Array

Int16Array が欲しいときには Rust の Vec<i16> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_int16array_into_rust_vec(numbers: Vec<i16>) -> Vec<i16> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_int16array_into_rust_vec(numbers: Int16Array): Int16Array;

 

Uint32Array

Uint32Array が欲しいときには Rust の Vec<u32> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_uint32array_into_rust_vec(numbers: Vec<u32>) -> Vec<u32> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_uint32array_into_rust_vec(numbers: Uint32Array): Uint32Array;

 

Int32Array

Int32Array が欲しいときには Rust の Vec<i32> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_int32array_into_rust_vec(numbers: Vec<i32>) -> Vec<i32> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_int32array_into_rust_vec(numbers: Int32Array): Int32Array;

 

BigUint64Array

BigUint64Array が欲しいときには Rust の Vec<u64> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_biguint64array_into_rust_vec(numbers: Vec<u64>) -> Vec<u64> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_biguint64array_into_rust_vec(numbers: BigUint64Array): BigUint64Array;

 

BigInt64Array

BigInt64Array が欲しいときには Rust の Vec<i64> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_bigint64array_into_rust_vec(numbers: Vec<i64>) -> Vec<i64> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_bigint64array_into_rust_vec(numbers: BigInt64Array): BigInt64Array;

 

Float32Array

Float32Array が欲しいときには Rust の Vec<f32> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_float32array_into_rust_vec(numbers: Vec<f32>) -> Vec<f32> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_float32array_into_rust_vec(numbers: Float32Array): Float32Array;

 

Float64Array

Float64Array が欲しいときには Rust の Vec<f64> を使います。

// Rust

#[wasm_bindgen]
pub fn from_ts_float64array_into_rust_vec(numbers: Vec<f64>) -> Vec<f64> {
    console::log_1(&format!("value: {:?}", numbers).into());
    return numbers;
}
// .d.ts

export function from_ts_float64array_into_rust_vec(numbers: Float64Array): Float64Array;

 

クロージャー

関数・クロージャーで型付けする上手い方法は無いようです。
限定的ではありますが Rust の js_sys::Function で型付けすると .d.ts で Function 型が生成されます。

// Rust

#[wasm_bindgen]
pub fn from_ts_closure_into_js_sys_function(f: js_sys::Function) -> js_sys::Function {
    console::log_1(&format!("value: {:?}", f).into());
    return f;
}
// .d.ts

export function from_ts_closure_into_js_sys_function(f: Function): Function;

とはいえ引数や返り値の型を明示できないのでいまひとつですね。

 

タプル

タプルが欲しいときには Rust のタプル構造体 (tuple struct) を使います。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct MyTuple(u8, String, bool);

#[wasm_bindgen]
pub fn from_ts_tuple_into_rust_struct(tuple: MyTuple) -> MyTuple {
    console::log_1(&format!("value: {:?}", tuple).into());
    return tuple;
}
// .d.ts

export type MyTuple = [number, string, boolean];

export function from_ts_tuple_into_rust_struct(tuple: MyTuple): MyTuple;

 

ユニオン型

ユニオン型が欲しいときには Rust の Enum を使います。

文字列リテラル型のユニオン

特によく使う文字列リテラル型のユニオンを見てみましょう。
"Foo" | "Bar" | "Baz" のような型が欲しいときにはこのようにします。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub enum StringLiteralTypeUnion {
    Foo,
    Bar,
    Baz,
}
// .d.ts

export type StringLiteralTypeUnion = "Foo" | "Bar" | "Baz";

 

小文字はじまりで "foo" | "bar" | "baz" のような型が欲しいときには、#[serde(rename_all = "camelCase")] 属性を付けましょう。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
#[serde(rename_all = "camelCase")]
pub enum StringLiteralTypeUnion {
    Foo,
    Bar,
    Baz,
}
// .d.ts

export type StringLiteralTypeUnion = "foo" | "bar" | "baz";

 

その他のユニオン

Rust の値付き列挙子は .d.ts では object のユニオンとして表現されます。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub enum ObjectUnion {
    N(u32),
    S(String),
    B(bool),
    Tuple(u32, String, bool),
    Named { n: u32, s: String, b: bool },
}
// .d.ts

type ObjectUnion =
    | { N: number }
    | { S: string }
    | { B: boolean }
    | { Tuple: [number, string, boolean] }
    | { Named: { n: number; s: string; b: boolean } };

 

interface

素朴に interface が欲しいときには Rust の構造体 (named struct) を使います。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct MyInterface {
    n: u8,
    s: String,
    b: bool,
}
// .d.ts

export interface MyInterface {
    n: number;
    s: string;
    b: boolean;
}

 

省略可能プロパティ・Nullable プロパティ

interface の中で省略可能プロパティ・Nullable プロパティを表現するには Rust の Option<T> を使います。
Option<T>T | null に解釈されます。さらに #[tsify(optional)] 属性を付けると T | null | undefined に解釈され、省略可能になります。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct ObjectHasOptionalProperty {
    pub nullable_property: Option<String>,

    #[tsify(optional)]
    pub optional_property: Option<String>,
}
// .d.ts

export interface ObjectHasOptionalProperty {
    nullable_property: string | null;
    optional_property?: string;
}

 

Record<K, T>

interface ではあらかじめ決められたキーしか指定できません。任意のキーをもつ object を扱いたい場合には Record<K, T> 型が欲しくなります。
Rust の HashMap<K, T> を使います。

// Rust

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

export type MyRecord = Record<string, number>;

 

Map<K, T>

Map<K, T> が欲しいときには Rust の HashMap<K, T> を使います。
ただし tsify-next に対して features = ["js"] を有効にしてコンパイルする必要があります。そのため Map<K, T>Record<K, T> を共存させることはできません。

# Cargo.toml

[dependencies.tsify-next]
version = "0.5.4"
features = ["js"]
// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct MyMap(HashMap<String, u32>);
// .d.ts

export type MyMap = Map<string, number>;

 

省略可能引数

関数の引数に Option<T> を使うと T | undefined に解釈されます。

// Rust

#[wasm_bindgen]
pub fn from_ts_nullable_string_into_rust_option_string(x: Option<String>, y: String) -> String {
    console::log_1(&format!("value: {:?}", x).into());
    console::log_1(&format!("value: {:?}", y).into());
    x.unwrap_or(y)
}
// .d.ts

export function from_ts_nullable_string_into_rust_option_string(x: string | undefined, y: string): string;

 

さらにその引数が末尾引数であれば省略可能引数になります。

// Rust

#[wasm_bindgen]
pub fn from_ts_optional_parameter_into_rust_option_string(x: Option<String>) -> Option<String> {
    console::log_1(&format!("value: {:?}", x).into());
    return x;
}
// .d.ts

export function from_ts_optional_parameter_into_rust_option_string(x?: string): string | undefined;

 

namespace

Rust の Enum から TypeScript の namespace を生成できます。

// Rust

#[derive(Tsify, Serialize, Deserialize, Debug)]
#[tsify(namespace, into_wasm_abi, from_wasm_abi)]
pub enum Color {
    Red,
    Blue,
    Green,
    Rgb(u8, u8, u8),
    Hsv {
        hue: f64,
        saturation: f64,
        value: f64,
    },
}
// .d.ts

declare namespace Color {
    export type Red = "Red";
    export type Blue = "Blue";
    export type Green = "Green";
    export type Rgb = { Rgb: [number, number, number] };
    export type Hsv = { Hsv: { hue: number; saturation: number; value: number } };
}

 

おわりに

いかがでしょうか。Rust の型と .d.ts を丁寧に対応づけることで Rust と JavaScript の両方から「同じ型」を参照するという体験が得られます。
wasm-bindgen も tsify-next もまだまだ発展途中です。今後いま以上に型の表現力が得られることを期待したいですね。

 

 

私からは以上です。