Result 型を使ってエラーハンドリングする

コマンドの戻り値を Result にして失敗を JS に返す。String から始め、thiserror と手書きの Serialize で ? が使える独自エラー型を作り、kind と message の形で返す方法を示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約9分 rust-004
目次
  1. 前提条件
  2. 1. まずは Result<T, String> で返す (Rust)
  3. 2. thiserror で独自エラー型を作る (Rust)
  4. 返す形の選び方
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

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 + 上の手書きの SerializeJS で種類ごとに処理を分ける
文字列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_kind exists for reference &Result<…>, but its trait bounds were not satisfied」: Err の型が Serialize を実装していないときのコンパイルエラーです。文面に Serialize の文字が出ないので気付きにくい点に注意します。自作の型なら Serialize を実装し、anyhow::Error などは上の方法で変換します。
  • 「the trait bound std::io::Error: serde::Serialize is not satisfied」: io::Error などを包んだ enum に #[derive(Serialize)] を付けています。手書きの Serialize に切り替えます。
  • 「? couldn't convert the error to AppError」: #[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 のシリアライズを行う も参考になります。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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