🌟

[Tauri v2]ts-rsクレートを使ってRust(Tauri)とTypeScript(フロントエンド)で型の共有を行う

2024/10/23に公開

概要

Tauri:

https://v2.tauri.app/

を使うと、アプリの本体部分をRustで記述することができ、
UI(フロントエンド)部分はWebサイトのノウハウを使って記述できる。
すなわち、後者はTypeScriptベースのフレームワークやツールを使って作成できる。
(使わないことも出来るが、ここではTSベースの何かを使うものとする)

ここで、UI側(TS)とアプリ本体側(Rust)で、IPC通信などで情報をやり取りすることがあるが、
異なる言語をまたいでいるため、スキーマ定義がやや大変なところがある。

つまり、

  • フロントエンド用に、TypeScriptの型定義を記述
  • アプリ本体用に、Rustでstructやenumを定義

といった形で、TS側とRust側で同一の情報を二重に記述しないといけなくなる課題が出てきたりする。
こうなると、記述が冗長で大変なことに加え、二重管理となるため将来的にデグレの懸念がでてくる(つまり、DRYでない)。

この課題に対して、

https://docs.rs/ts-rs/10.0.0/ts_rs/index.html

を使うことである程度はマシに出来そう、というのが本記事の内容となる。

実践例

https://v2.tauri.app/start/create-project/

などを見て自動で作成できるサンプルプロジェクトをベースに、適宜変更を加えていく。

(検証環境の情報など)

  • Linux(WSL2)
    • WSL バージョン: 2.3.24.0
    • distribution: fedora40
  • Rust: 1.82.0
  • Node: v20.18.0

その他、

https://v2.tauri.app/start/prerequisites/

を参照して、Tauriアプリのビルドに必要なツールなどをインストールしておく。

Step1. プロジェクトの作成

https://v2.tauri.app/start/create-project/#using-create-tauri-app

のように、

# 使うパッケージマネージャーは好みで適宜変更する(npmやyarn, bunなど)
pnpm create tauri-app

でプロジェクトを作成する。

本質ではないので詳述はしないが、今回は

  • パッケージマネージャー: pnpm
  • フロントエンド: TypeScript + React(viteベース)

のような感じで作成している。

ここで、自動作成したサンプルでは、

のような感じで、Formに名前(name)を入力し、"Greet"ボタンを押すことで
Tauri側への情報の送信と、Tauriからの情報受信を行う例が初めから組み込まれている。

詳細なガイドは公式ドキュメントの"Calling Rust from the Frontend":

https://v2.tauri.app/develop/calling-rust/#commands

に記述されてのでそちらを参照されたいが、とりあえず今回の例では

src-tauri/src/lib.rs(抜粋)
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

// (...)

のようにRust側で定義されている関数greetを、

src/App.tsx(抜粋)
import { useState } from "react";
// (...)

function App() {
  const [greetMsg, setGreetMsg] = useState("");
  const [name, setName] = useState("");

  async function greet() {
    // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
    setGreetMsg(await invoke("greet", { name })); // ★invoke関数を使って、Tauri側の処理を呼び出し
  }
  return (
    <main className="container">
      <h1>Welcome to Tauri + React</h1>

      {/* (...) */}

      <form
        className="row"
        onSubmit={(e) => {
          e.preventDefault();
          greet(); // ★ 上で定義した関数: greetを実行する中で、Tauriの関数呼び出しを行う`invoke`を実行
        }}
      >
        <input
          id="greet-input"
          onChange={(e) => setName(e.currentTarget.value)}
          placeholder="Enter a name..."
        />
        <button type="submit">Greet</button>
      </form>
      <p>{greetMsg}</p>
    </main>
  );
}

export default App;

のような形で呼び出して使っている。

ここで、大事なのがawait invoke("greet", { name })部分になるが、
greetを第1引数に指定する際に、

  • 第2引数に何を指定すれば良いのか
    • ここでは{ name }が正解だが、Tauri(Rust)側のコードを見ないと自明ではない
  • 返り値の型はどうなっているのか
    • ここではstring型を期待しているが、ここも同様にTauri(Rust)側のコードを参照しないと自明でない

といったところがあり、これがまさに冒頭部分で記述している課題となっている。

Step2. 既存のコードの変更

そこで、冒頭でも書いたようにts-rsを使ってRustのコードからTypeScriptの型を自動生成していく。

使い方は

https://github.com/Aleph-Alpha/ts-rs/tree/v10.0.0

に大まかに書いてあるが、deriveマクロを使ってstructやenumからtypescriptの型を自動生成出来るものになっている。

なお、cargo testを行うと型ファイルが生成される仕様となっているが、
tauri本体のコードに含めると実行時間がかなり長くなってしまって不便なことがあるので、
今回はworkspace機能を使ってメインのクレートからは分離する形をとる。

そこで、src-tauri/ipc-ifにRust-TypeScript間で共有する型を定義するだけのworkspaceを切り、

src-tauri/Cargo.toml(追記分のみ抜粋)
[dependencies]
"ipc-if" = { path = "./ipc-if" } # workspaceで書いたコードを呼び出せるようにしておく
# (...)

[workspace]
members = ["ipc-if"] # workspaceのメンバーを追加

また、src-tauri/ipc-ifcargo add ts_rsを行って、ts_rsをインストールしておく。
更に、TauriにおけるUI側とアプリ側の情報のやり取りは、内部的にserdeを使って行っているためcargo add serde -F deriveによって、serdeも入れておく。
(詳細はPassing ArgumentsおよびReturning Dataなどを参照)

(src-tauri/ipc-if/Cargo.toml)

以下のような感じ:

src-tauri/ipc-if/Cargo.toml
[package]
name = "ipc-if"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0.210", features = ["derive"] }
ts-rs = { version = "10.0.0" }

そして、↓のように必要な型をstruct, enumの形で定義していく:

src-tauri/ipc-if/src/greet.rs
use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(TS, Deserialize, Serialize)]
#[ts(export, export_to = "greet.ts")]
pub struct GreetArgs {
    pub name: String,
}

#[derive(TS, Deserialize, Serialize)]
#[ts(export, export_to = "greet.ts")]
pub struct GreetResponse {
    pub message: String,
}

// ↓※TypeScriptからinvokeで呼び出す関数名に相当する型を作っている
// (rename機能を使って、TSにおける型の中身がRustでの関数名と同じになるようにする)
#[derive(TS, Deserialize, Serialize)]
#[ts(export, export_to = "greet.ts", rename_all = "snake_case")]
pub enum GreetChannelName {
    Greet,
}

上記のように、structやenumをソースコード中で定義したら、

# 作成したワークスペースへ移動
cd src-tauri/ipc-if

# src-tauri/ipc-if/binding/ 以下に型ファイルを生成
cargo test

のようにして型ファイルを生成できる。

なお、説明していなかったが、先ほどの例だと#[ts(export_to = "greet.ts")]のようにして型ファイルが生成される際のパスを指定している。
→デフォルトではbindings/ディレクトリ直下に、structまたはenum毎にバラバラにファイルが生成される。関連が深いもの同士で同じファイル/ディレクトリにまとめたり、適宜階層構造を作ったりする方が管理やTSからのimport時に便利だと思われる

詳細は

https://docs.rs/ts-rs/10.0.0/ts_rs/trait.TS.html#container-attributes

に記載があるので参照のこと。

ここで、生成された型ファイルの内容は以下のような感じになっている:

src-tauri/ipc-if/bindings/greet.ts
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type GreetArgs = { name: string, };

export type GreetChannelName = "greet";

export type GreetResponse = { message: string, };

めでたく(?)型ファイルが生成されている。

更に、TS側からimportしやすいようにtsconfig.jsonに以下を追記:

tsconfig.json(追記部分のみ)
{
  "compilerOptions": {
    // (...)

    // ↓のようにbaseUrlおよびpathsを追記・編集する
+   "baseUrl": ".",
+   "paths": {
+     "@ipc-if/*": ["./src-tauri/ipc-if/bindings/*"]
+   }
  },
  // (...)
}

こうしておくと、ts-rsで生成した型ファイルをimport type { Hoge } from '@ipc-if/***'のようにimportできる。

この上で、src/App.tsxを以下のように変更できる:

src/App.tsx
// (...)

// ↓ts-rsで自動生成した型ファイルをimport
+ import type { GreetArgs, GreetChannelName, GreetResponse } from "@ipc-if/greet";

// (...)

function App() {
  // (...)
  async function greet() {
    // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/

-   setGreetMsg(await invoke("greet", { name }));
+   const channel: GreetChannelName = "greet";
+   const args: GreetArgs = {
+     name,
+   };
+   const response: GreetResponse = await invoke(channel, { args });

+   setGreetMsg(response.message);
  }
  // (...)
}
export default App;

上記のようにして、(正しい型ファイルをimportしていれば)引数や返り値の型をTS側で認識してコーディングすることが可能になる。

最後に、Tauri(Rust)側のコードも書き換えておく。
フロントエンドと共有する型を作成していたipc-ifワークスペースからstructやenumなどをimport(use)して、invokeされた際に走る関数を書き換える。

src-tauri/src/lib.rs
+ use ipc_if::greet::{GreetArgs, GreetResponse};

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
- fn greet(name: &str) -> String {
-     format!("Hello, {}! You've been greeted from Rust!", name)
+ fn greet(args: GreetArgs) -> GreetResponse {
+     let message = format!("Hello, {}! You've been greeted from Rust!", args.name);
+
+     GreetResponse { message }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

このようにして、Tauri側でもフロントエンド側でも、(ipc-ifワークスペースで作成した)共通の型情報を使って関数の入出力を定義することができ、2重管理の問題を軽減することができている。

ここまででできたものは↓に置いている:

https://github.com/junkor-1011/tauri-v2-tsrs-example-2024/tree/zenn_20241022_minimal_example

おまけ、補足

エッセンス部分は既に書き終わったので詳述はしないが、
1つ例を追加したバージョンも作っていたのでリンク+エッセンスだけ書いて成仏させておく。

https://github.com/junkor-1011/tauri-v2-tsrs-example-2024/tree/zenn_20241022

追加した部分についてだが、

https://v2.tauri.app/develop/calling-rust/#error-handling

に記載されているように、invokeして呼び出すrust側の関数(command)について、Result型にして失敗時に返す型も指定できるので、その場合の例を追加している。
(今回作った例では、乱数によって、ランダムで成功したり失敗したりする関数を使っている)

また、共有する型について、featuresを追加することでRust側ではより詳細な型をつけられるようにしている
(chronoによる時刻型や、uuid型など)

ポイントとして、既に記述済みだが、
TSの型を生成するts-rsもserdeに基づいており、tauriもserdeを利用しているため、
とりあえずserde(とserde_json)に詳しくなっておくと柔軟に型の指定ができるようになる。

実際書いたのは↓

https://github.com/junkor-1011/tauri-v2-tsrs-example-2024/blob/zenn_20241022/src-tauri/ipc-if/src/random_example.rs

生成された型ファイル:

https://github.com/junkor-1011/tauri-v2-tsrs-example-2024/blob/zenn_20241022/src-tauri/ipc-if/bindings/random-example.ts

(Rust側ではUuid型やDateTime型になっていても、TS側ではstring型だったりする。挙動を考えれば仕方ない気はする)

個人的に少しハマった点として、
ts-rs用に#[ts(rename_all = "camelCase")]
などと書いている部分について、同じ内容をserde向けに
#[serde(rename_all = "camelCase")]などと書く必要があったりするので注意(?)

(改善点など)

はじめに書いたように、Rust側とTS側で同一の情報を指すもの(IF部分の型情報)をバラバラに書かなくて良くなっているのは良いが、

  • TS側で正しく型ファイルをimportして、それらを正しく組み合わせて使わないといけない
  • TS側で実行するinvoke関数の第一引数(本記事ではチャンネル名などと書いている)について、
    Rustで実行される関数名が相当するが、Rustの関数から完全に自動で関数名の型は生成できないため、結局手作業でenumを書いて型を作っている(つまりこの部分はDRYでない)

といった感じで、結局手作業による冗長な部分が残ってしまっている。

そのため、型情報の「パーツ」にとどまらず、理想的にはinvokeをラップするようなTypeScriptの関数くらいまでが一体的に自動生成されるような形になると望ましいと考えられる。
※末尾に書いたその他候補なども参照

その他候補

tauri-spectra

https://github.com/specta-rs/tauri-specta

この記事を書き始めてから存在に気づいたので😔残念ながら詳細は良く把握できていないものの、かなり便利そう

ただし、執筆時点(2024-10-22)ではtauri v2用のものはまだstableではない模様。
(現在はrc版)

現時点での使用例は

https://github.com/specta-rs/tauri-specta/tree/v2.0.0-rc.20/examples/app

に置かれており、

https://github.com/specta-rs/tauri-specta/blob/v2.0.0-rc.20/examples/app/src-tauri/src/main.rs

のように、Rust側でderiveマクロ(spectra::Typetauri_spectra::Eventなど)をつけておくと、
↓のようにTypeScriptのコードを自動生成できるらしい:

https://github.com/specta-rs/tauri-specta/blob/v2.0.0-rc.20/examples/app/src/bindings.ts

ぱっと見では、先ほど書いた本記事の内容の改善点を解消できていそう(?)な感じがしており、早くstableになるのが期待されるところ。

rspc

https://www.rspc.dev/

こちらも特に自分では試せていないが、TypeScriptのtrpcに影響を受けて作られたもののようで、
Rustで書いたバックエンドロジックにTypeScriptからアクセスするクライアントコードを生成できるらしい?

Tauriとの連携機能も用意されている。

https://www.rspc.dev/integrations/tauri

しかしながら、執筆時点での最新版がv0.2.0(2024-03-18)とやや古くなっており、Tauri v2でうまく動いてくれるのかは未検証。

こちらも今後の動向が気になるところ。

GitHubで編集を提案

Discussion