Tauri のコマンドは戻り値を Result<T, E> にでき、Ok は invoke の resolve、Err は reject として JS に届きます。手軽なのは E を String にする方法ですが、失敗の種類を JS で見分けたい、? で下位のエラーをそのまま返したい、となると独自のエラー型が欲しくなります。このレシピでは thiserror と serde::Serialize を組み合わせたエラー型を Rust 側から作ります。JS 側での受け取り方(try / catch と型の判定)は Rust 側で起きたエラーを JS でキャッチする を参照してください。
前提条件
プラグインや権限の設定は不要です。独自エラー型に thiserror を使うので、src-tauri で追加します。serde と serde_json はテンプレートに最初から入っています。
cd src-tauri
cargo add thiserror
1. まずは Result<T, String> で返す (Rust)
E を String にすると、Err の文字列がそのまま JS の catch に届きます。標準ライブラリなどのエラーは map_err(|e| e.to_string()) で文字列にしてから ? で返します。
#[tauri::command]
fn read_memo(path: String) -> Result<String, String> {
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
if text.trim().is_empty() {
return Err("メモが空です".into());
}
Ok(text)
}
小さなアプリならこれで足ります。困るのは、JS で「ファイルが無い」と「中身が不正」で処理を分けたくなったときです。文字列の中身で分岐するしかなく、しかも std::io::Error の文言は OS と言語設定で変わる(日本語の Windows では日本語になる)ので、分岐はすぐ壊れます。コマンドが増えてきたら次の独自エラー型に切り替えます。
2. thiserror で独自エラー型を作る (Rust)
thiserror は、#[error("...")] に書いた文言で Display(to_string() の結果)と std::error::Error を実装してくれるクレートです。#[from] を付けたバリアントには From も実装されるので、コマンドの中で ? を書くだけで下位のエラーが自分の型に変わります。
ただし Tauri に返すには Serialize も必要です。std::io::Error や serde_json::Error は Serialize を実装していないため、それらを包む enum に #[derive(Serialize)] は付けられません。そこで Serialize を手で実装し、JS には「種類(kind)」と「文言(message)」の 2 つだけを渡します。
use serde::ser::{SerializeStruct, Serializer};
use serde::{Deserialize, Serialize};
use tauri::Manager;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("ファイルを読み書きできません: {0}")]
Io(#[from] std::io::Error),
#[error("設定ファイルの形式が正しくありません: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)] // 中のエラーの文言をそのまま使う
Tauri(#[from] tauri::Error),
#[error("{0}が空です")]
Empty(&'static str),
}
impl AppError {
/// JS で分岐に使う短い識別子
fn kind(&self) -> &'static str {
match self {
AppError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => "notFound",
AppError::Io(_) => "io",
AppError::Json(_) => "invalidFormat",
AppError::Tauri(_) => "tauri",
AppError::Empty(_) => "validation",
}
}
}
// JS には { kind, message } の形で届く
impl Serialize for AppError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut s = serializer.serialize_struct("AppError", 2)?;
s.serialize_field("kind", self.kind())?;
s.serialize_field("message", &self.to_string())?;
s.end()
}
}
/// コマンドの戻り値に使う別名
pub type CmdResult<T> = Result<T, AppError>;
コマンドでは ? を書くだけです。app_config_dir() の tauri::Error、ファイル操作の std::io::Error、JSON の serde_json::Error が、それぞれ #[from] で AppError に変わります。
#[derive(Serialize, Deserialize)]
pub struct Settings {
theme: String,
language: String,
}
#[tauri::command]
fn load_settings(app: tauri::AppHandle) -> CmdResult<Settings> {
let path = app.path().app_config_dir()?.join("settings.json");
let text = std::fs::read_to_string(&path)?;
let settings: Settings = serde_json::from_str(&text)?;
if settings.theme.is_empty() {
return Err(AppError::Empty("テーマ"));
}
Ok(settings)
}
#[tauri::command]
fn save_settings(app: tauri::AppHandle, settings: Settings) -> CmdResult<()> {
let dir = app.path().app_config_dir()?;
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("settings.json"), serde_json::to_string_pretty(&settings)?)?;
Ok(())
}
/// Tauri の API を呼ぶだけなら tauri::Result をそのまま返せる(JS には文字列で届く)
#[tauri::command]
fn rename_window(window: tauri::WebviewWindow, title: String) -> tauri::Result<()> {
window.set_title(&title)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![read_memo, load_settings, save_settings, rename_window])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
返す形の選び方
| JS に届く形 | Rust 側の書き方 | 向いている場面 |
|---|---|---|
| 文字列 | Result<T, String> | 小さなアプリ、表示するだけ |
| 文字列 | thiserror + serialize_str(&self.to_string()) | ? は使いたいが、JS では表示するだけ |
{ kind, message } | thiserror + 上の手書きの Serialize | JS で種類ごとに処理を分ける |
| 文字列 | tauri::Result<T> | Tauri の API を呼ぶだけのコマンド |
kind の値は JS との約束事なので、一度決めたら変えないようにします。message は利用者に見せる前提で書き、原因の詳細({:?} で出る内容)は Rust 側でログに残します。anyhow を使っている場合、anyhow::Error は Serialize を実装していないので直接は返せません。戻り値を Result<T, tauri::ipc::InvokeError> にし、map_err(tauri::ipc::InvokeError::from_anyhow) で変換すると文字列として届きます。
動作確認
npm run tauri dev で起動し、次の関数をボタンなどから呼びます。結果は開発者ツールのコンソールに出ます。
import { invoke, type InvokeArgs } from '@tauri-apps/api/core';
async function show(cmd: string, args?: InvokeArgs) {
try {
console.log(cmd, await invoke(cmd, args));
} catch (e) {
console.error(cmd, e); // Err の中身がそのまま届く
}
}
export async function tryErrors() {
await show('read_memo', { path: 'C:/no/such/memo.txt' });
await show('save_settings', { settings: { theme: '', language: 'ja' } });
await show('load_settings');
}
日本語の Windows では次のように出ます。read_memo は文字列、load_settings は { kind, message } のオブジェクトです。
read_memo 指定されたパスが見つかりません。 (os error 3)
save_settings null
load_settings {kind: 'validation', message: 'テーマが空です'}
settings.json を消してから load_settings を呼ぶと kind が notFound、中身を JSON でない文字にすると invalidFormat になります。JS 側で kind を見て処理を分ける書き方は Rust 側で起きたエラーを JS でキャッチする を参照してください。
よくあるエラーと対処法
- 「the method
blocking_kindexists for reference&Result<…>, but its trait bounds were not satisfied」:Errの型がSerializeを実装していないときのコンパイルエラーです。文面に Serialize の文字が出ないので気付きにくい点に注意します。自作の型ならSerializeを実装し、anyhow::Errorなどは上の方法で変換します。 - 「the trait bound
std::io::Error: serde::Serializeis not satisfied」:io::Errorなどを包んだ enum に#[derive(Serialize)]を付けています。手書きのSerializeに切り替えます。 - 「
?couldn't convert the error toAppError」:#[from]を付けていない型のエラーに?を使っています。#[from]のバリアントを足すか、map_errでどのバリアントにするかを明示します。 - JS で
messageが英語だったり日本語だったりする:io::Errorの文言は OS と言語設定で変わります。分岐にはkindを使い、messageは表示専用にします。
OS ごとの違いと注意点
- 共通: エラーの受け渡しに OS の違いはありませんが、
io::Errorの文言は OS ごとに異なります(Windows の日本語環境では「指定されたファイルが見つかりません。 (os error 2)」のように日本語)。 - async コマンド: 引数に
&strやStateを使う async コマンドは、戻り値がResultでないとコンパイルできません。CmdResult<T>のような別名でも条件を満たします(非同期(async)コマンドを定義する)。 - panic は Err にならない:
unwrap()の失敗などの panic はcatchに届かず、アプリの終了につながることがあります。失敗しうる処理は?でErrにし、想定外の panic への備えは パニック(クラッシュ)時の処理を書く で行います。 thiserrorのエラー型はstd::error::Errorを実装しているので、setupフックの中でも?でそのまま返せます。独自型の手書きのSerializeは serde の仕組みそのものなので、細かい調整は Serde で JSON のシリアライズを行う も参考になります。
