Rust コマンドからの戻り値を受け取る

invoke<T> で Rust コマンドの戻り値を型付きで受け取る。構造体・Vec・Option が JS でどんな形になるか、serde の属性での調整、大きなバイナリを Response で返す方法も示す。

フロントエンド 対象: Tauri 2.x 更新日: 読了目安: 約7分 front-003
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 型対応の早見表
  4. 大きなバイナリは Response で受け取る
  5. 2. バックエンドから実装する (Rust)
  6. serde の属性で JS に届く形を変える
  7. 動作確認
  8. よくあるエラーと対処法
  9. the trait bound MyStruct: serde::Serialize is not satisfied という趣旨のコンパイルエラー
  10. 値は届いているのに undefined になるフィールドがある
  11. OS ごとの違いと注意点
  12. 関連レシピ

Rust コマンドが返した値は JSON にシリアライズされ、invoke の Promise の解決値としてフロントエンドに届きます。文字列や数値ならそのまま、構造体は serde::Serialize を derive するだけで JS のオブジェクトになります。このレシピでは「Rust の型が JS でどんな値になるか」をフロントエンド視点で整理し、Rust 側の書き方は 2 章にまとめます。Err を返したときの受け方は Rust 側で起きたエラーを JS でキャッチする を参照してください。

前提条件

追加プラグインや権限設定は不要です。構造体を返すには serde の derive 機能が必要ですが、テンプレートの src-tauri/Cargo.toml には最初から入っています。

1. フロントエンドから実装する (TypeScript)

invoke<T> の T は「返ってくる JSON をこう解釈する」という 宣言 で、実行時の検証はありません。Rust の構造体と同じ形の interface を用意し、フィールド名の変換ルールを揃えるのがポイントです。

// src/main.ts
import { invoke } from '@tauri-apps/api/core';

// Rust の struct User に対応する型(Rust 側は rename_all = "camelCase" 前提)
interface User {
  id: number;
  username: string;
  isActive: boolean;
  email: string | null; // Option<String> は null になる
}

async function showUser() {
  const user = await invoke<User>('get_current_user');        // 構造体 -> オブジェクト
  const users = await invoke<User[]>('list_users');           // Vec -> 配列
  const found = await invoke<User | null>('find_user', { id: 42 }); // Option

  console.log(user.username, users.length);
  if (found === null) console.log('該当ユーザーなし');
}

型対応の早見表

Rust の型JS で受け取る値
String, &strstring
i32, u32, f64 などnumber
boolboolean
()undefined
Option<T>T または null
Vec<T>T[](Vec<u8> も 数値の配列)
#[derive(Serialize)] structオブジェクト
tauri::ipc::ResponseArrayBuffer

大きなバイナリは Response で受け取る

Vec<u8> をそのまま返すと [137, 80, 78, ...] のような JSON 配列(number[])になり、数 MB のファイルでは文字列化とパースのコストが目立ちます。Rust 側で tauri::ipc::Response に包むと JSON を経由せずに ArrayBuffer で届きます。

import { invoke } from '@tauri-apps/api/core';

const buf = await invoke<ArrayBuffer>('read_image');
const blob = new Blob([new Uint8Array(buf)], { type: 'image/png' });
document.querySelector<HTMLImageElement>('#preview')!.src = URL.createObjectURL(blob);

2. バックエンドから実装する (Rust)

フィールド名を JS の慣習に合わせるため、構造体に #[serde(rename_all = "camelCase")] を付けています。

// src-tauri/src/lib.rs
use serde::Serialize;
use tauri::ipc::Response;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct User {
    id: u32,
    username: String,
    is_active: bool,       // JS では isActive
    email: Option<String>, // None は null
}

#[tauri::command]
fn get_current_user() -> User {
    User { id: 1, username: "admin".into(), is_active: true, email: None }
}

#[tauri::command]
fn list_users() -> Vec<User> { vec![get_current_user()] }

#[tauri::command]
fn find_user(id: u32) -> Option<User> {
    if id == 1 { Some(get_current_user()) } else { None }
}

#[tauri::command]
fn read_image() -> Result<Response, String> {
    let data = std::fs::read("assets/sample.png").map_err(|e| e.to_string())?;
    Ok(Response::new(data))
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_current_user, list_users, find_user, read_image
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

serde の属性で JS に届く形を変える

Option<T> の None は既定では null で届きますが、#[serde(skip_serializing_if = "Option::is_none")] を付けるとキーごと省かれ、JS では undefined になります。TypeScript の型も string | null ではなく省略可能なプロパティになるので、アプリの中でどちらかに揃えます。パスワードのハッシュのように JS へ渡したくないフィールドは #[serde(skip)] で外せます。画面用に別の構造体を作らずに済みます。

use serde::Serialize;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Profile {
    display_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    avatar_url: Option<String>, // None ならキーごと省く(JS では undefined)
    #[serde(skip)]
    password_hash: String, // JS には送らない
}

#[tauri::command]
fn get_profile() -> Profile {
    Profile {
        display_name: "admin".into(),
        avatar_url: None,
        password_hash: String::from("dummy"),
    }
}

get_profile もほかのコマンドと同じく generate_handler! に登録します。受け取る側では、省かれうるフィールドを ? 付きのプロパティにします。

import { invoke } from '@tauri-apps/api/core';

interface Profile {
  displayName: string;
  avatarUrl?: string; // キーが無いことがある(null ではない)
}

const profile = await invoke<Profile>('get_profile');
console.log(profile.avatarUrl ?? '(未設定)'); // ?? なら null と undefined の両方に効く

動作確認

npm run tauri dev で起動し、showUser() を実行して user を console.log すると次のように表示されます。

{ id: 1, username: "admin", isActive: true, email: null }

rename_all を外すと is_active のままになり、user.isActive は undefined になります(型エラーにはなりません)。read_image の戻り値は ArrayBuffer(12345) のように表示されます。

よくあるエラーと対処法

the trait bound MyStruct: serde::Serialize is not satisfied という趣旨のコンパイルエラー

戻り値の型(またはそのフィールドの型)が Serialize を実装していません。自作の構造体には #[derive(Serialize)] を付けます。std::fs::File や Mutex のようなリソース型は返せないので、必要な情報だけを取り出した構造体に詰め替えます。

値は届いているのに undefined になるフィールドがある

Rust の snake_case と TypeScript の camelCase の食い違いです。構造体に #[serde(rename_all = "camelCase")] を付けるか、interface 側を is_active に合わせます。どちらかに統一しないと、Rust に送り返すときにも同じ問題が起きます。

OS ごとの違いと注意点

シリアライズの挙動は OS に依存しません。注意点はデータの中身と量です。

  • パスを返すとき: Windows では C:\\Users\\... のようにバックスラッシュ区切りの文字列になります。表示用に / へ置換するなど、フロント側で扱いを決めておきます。
  • u64 / i64 の大きな値: JS の number は 2^53 までしか正確に表せません。64 bit の ID などは Rust 側で String にして返します。
  • 大きなデータの往復: 数十 MB の JSON を 1 回で返すとメモリと時間を大きく消費します。一覧はページングし、バイナリは Response、連続データはイベントか Channel に切り替えます。
  • enum の形式: データ付きバリアントは既定で { "Variant": {...} } の外部タグ形式になります。JS で扱いやすくするには #[serde(tag = "type")] を検討してください(Serde で JSON のシリアライズを行う)。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する