この記事は 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 から呼び出すテストコードもあるので挙動の理解に役立つはずです。
事前準備
下記のクレートを使用します。
事前に 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 もまだまだ発展途中です。今後いま以上に型の表現力が得られることを期待したいですね。
私からは以上です。