2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

thiserror vs anyhowで設計するRustエラーハンドリング実践ガイド

2
Last updated at Posted at 2026-03-23

thiserror vs anyhowで設計するRustエラーハンドリング実践ガイド

この記事でわかること

  • thiserror(2.0)とanyhow(1.0)の設計思想の違いと、プロジェクト規模に応じた選定基準
  • レイヤードアーキテクチャでthiserrorとanyhowを組み合わせる実装パターン
  • ?演算子・Fromトレイト・Contextトレイトを活用したエラー伝播の具体的な実装例
  • 大規模プロジェクトでのエラー設計戦略(snafu・eyreとの比較を含む)
  • thiserror 2.0の主な変更点と移行時の注意点

対象読者

  • 想定読者: Rustの基本文法を理解しており、Result<T, E>?演算子を使ったことがある開発者
  • 必要な前提知識:
    • Rustの基礎文法(enum、struct、trait)
    • Result型とOption型の基本的な使い方
    • cargo でのクレート追加方法

結論・成果

thiserrorとanyhowは「ライブラリ vs アプリケーション」ではなく、**「エラーを構造的に扱う場面 vs エラーを報告する場面」**で使い分けるのが実践的です。レイヤードアーキテクチャでは、ドメイン層・インフラ層にthiserrorで型付きエラーを定義し、アプリケーション層でanyhowに集約するパターンが広く採用されています。thiserrorのカスタムエラー生成コストは約7ns、anyhow::Errorのメモリサイズは8bytesと報告されており、パフォーマンスへの影響は実用上無視できる水準です。

Rustのエラーハンドリングの基礎を理解する

Rustのエラーハンドリングは、Result<T, E>型を基盤としたコンパイル時の安全性保証が特徴です。例外機構を持たないRustでは、すべてのエラーが型として表現され、?演算子で伝播されます。

std::error::Errorトレイトとエラーチェーン

標準ライブラリのErrorトレイトは、Rustにおけるエラー型の共通インターフェースです。以下の3つのメソッドが基盤となります。

// std::error::Error トレイトの主要メソッド(簡略化)
pub trait Error: Display + Debug {
    // エラーの原因となった下位エラーを返す
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        None
    }
}

Displayトレイトがユーザー向けのエラーメッセージを、Debugトレイトが開発者向けの詳細情報を提供します。source()メソッドはエラーチェーンを構築し、根本原因まで辿ることを可能にします。

手動実装の課題

Errorトレイトを手動で実装すると、ボイラープレートが膨大になります。

use std::fmt;

#[derive(Debug)]
enum AppError {
    Database(String),
    Network(std::io::Error),
    Validation { field: String, message: String },
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::Database(msg) => write!(f, "データベースエラー: {}", msg),
            AppError::Network(err) => write!(f, "ネットワークエラー: {}", err),
            AppError::Validation { field, message } => {
                write!(f, "バリデーションエラー: {} - {}", field, message)
            }
        }
    }
}

impl std::error::Error for AppError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            AppError::Network(err) => Some(err),
            _ => None,
        }
    }
}

// さらに From<std::io::Error> の実装も必要...
impl From<std::io::Error> for AppError {
    fn from(err: std::io::Error) -> Self {
        AppError::Network(err)
    }
}

エラーバリアントが増えるたびにDisplayErrorFromの3つの実装を更新する必要があり、保守コストが高くなります。この課題を解決するのがthiserrorとanyhowです。

thiserrorで型安全なエラー型を定義する

thiserrorは、dtolnay氏が開発するderiveマクロクレートです。#[derive(Error)]を付けるだけで、DisplayErrorFromトレイトの実装を自動生成します。2026年3月時点の最新バージョンは2.0.18で、約571,000のプロジェクトが依存しています。

基本的な使い方

# Cargo.toml
[dependencies]
thiserror = "2.0"
use thiserror::Error;

#[derive(Error, Debug)]
enum RepositoryError {
    #[error("レコードが見つかりません: id={id}")]
    NotFound { id: i64 },

    #[error("データベース接続エラー")]
    Connection(#[from] sqlx::Error),

    #[error("一意制約違反: {0}")]
    DuplicateKey(String),

    #[error("不正なクエリパラメータ: {field} は {constraint} を満たす必要があります")]
    InvalidParameter {
        field: String,
        constraint: String,
    },
}

このコードで自動生成される実装を整理すると、以下のようになります。

アトリビュート 自動生成される実装 用途
#[error("...")] Displayトレイト ユーザー向けエラーメッセージ
#[from] From<T>トレイト ?演算子での自動変換
#[source] Error::source() エラーチェーンの構築
#[error(transparent)] Displaysourceの委譲 ラッパー型エラー

#[from]#[source]の違いを理解する

#[from]Fromトレイトの実装とsource()の返却を両方生成します。一方、#[source]source()のみを生成し、Fromは生成しません。

use thiserror::Error;

#[derive(Error, Debug)]
enum ServiceError {
    // #[from]: From<sqlx::Error> + source() を両方生成
    // 同一型のFromは1つのenumに1つだけ
    #[error("データ取得エラー")]
    FetchFailed(#[from] sqlx::Error),

    // #[source]: source() のみ生成(Fromは生成しない)
    // 同一型の source は複数バリアントで使える
    #[error("データ保存エラー: テーブル {table}")]
    SaveFailed {
        #[source]
        cause: sqlx::Error,
        table: String,
    },
}

注意: #[from]は1つのenum定義内で同一型に対して1回しか使えません。同じ型のエラーを異なるコンテキストで区別したい場合は、#[source]を使い、手動で変換コードを書きます。

thiserror 2.0の主な変更点

thiserror 1.xから2.0への移行で注意すべき変更点は以下の通りです。

// 1. フォーマット文字列が std の format! マクロと同じルールに統一
// thiserror 1.x: 暗黙的なフィールドキャプチャの挙動が独自
// thiserror 2.0: std::fmt と完全に一致

// 2. r#source でsourceフィールド名のオプトアウトが可能に
#[derive(Error, Debug)]
#[error("接続エラー")]
struct ConnectionError {
    // "source" という名前だが Error::source() として扱わない
    r#source: String,
}

// 3. thiserror クレートへの直接依存が必須に
// 再エクスポート経由での利用は非推奨

なぜこの変更が行われたか:

  • フォーマット文字列の統一により、std::fmtと同じ知識でエラーメッセージを書けるようになりました
  • r#source対応により、「source」という名前のフィールドを持つ外部型のラッパーが書きやすくなりました

anyhowでアプリケーションのエラー伝播を簡素化する

anyhowは、同じくdtolnay氏が開発するエラーハンドリングクレートです。anyhow::Error任意のstd::error::Error実装型を受け入れるトレイトオブジェクトで、メモリサイズはわずか8bytesです。2026年3月時点の最新バージョンは1.0.102です。

基本的な使い方

# Cargo.toml
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result, bail, ensure};

// anyhow::Result<T> は Result<T, anyhow::Error> のエイリアス
fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context(format!("設定ファイル '{}' の読み込みに失敗", path))?;

    let config: Config = toml::from_str(&content)
        .context("TOMLパースエラー")?;

    // ensure! マクロ: 条件が false なら Err を返す
    ensure!(config.port > 0, "ポート番号は正の整数である必要があります: {}", config.port);

    // bail! マクロ: 即座に Err を返す
    if config.database_url.is_empty() {
        bail!("database_url が設定されていません");
    }

    Ok(config)
}

anyhowの主要API

API 用途 Python/MLエンジニア向け類推
anyhow::Result<T> 戻り値の型エイリアス 例外をキャッチする try/except のようなもの
.context("msg") エラーにコンテキスト情報を追加 raise ... from e のメッセージ追加に相当
.with_context(|| ...) 遅延評価でコンテキスト追加 Happy pathではコスト0
bail!("msg") 即座にエラーを返す raise ValueError("msg") に相当
ensure!(cond, "msg") 条件チェック付きエラー assert に近いがリカバリ可能
anyhow!("msg") エラー値を生成 ValueError("msg") に相当

contextとwith_contextの使い分け

use anyhow::{Context, Result};

fn process_record(id: i64) -> Result<()> {
    let data = fetch_data(id)
        // context: 文字列リテラルや事前生成済み文字列を渡す場合
        .context("データ取得に失敗")?;

    let result = transform(data)
        // with_context: 動的な情報を含む場合(クロージャで遅延評価)
        // エラーが発生しない限り format! は実行されない
        .with_context(|| format!("レコード id={} の変換に失敗", id))?;

    save(result).context("保存に失敗")?;
    Ok(())
}

with_contextはクロージャを受け取るため、Happy path(正常系)ではフォーマット処理が実行されません。ループ内で大量の?を使う場合はwith_contextを選ぶことでパフォーマンスへの影響を抑えられます。

anyhow::Errorのダウンキャスト

anyhowで受け取ったエラーを後から型で判別したい場合、downcast_refが使えます。

use anyhow::Result;

fn handle_error(err: &anyhow::Error) {
    // 元のエラー型に戻してパターンマッチ
    if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
        match io_err.kind() {
            std::io::ErrorKind::NotFound => {
                eprintln!("ファイルが見つかりません: {}", io_err);
            }
            std::io::ErrorKind::PermissionDenied => {
                eprintln!("権限がありません: {}", io_err);
            }
            _ => eprintln!("IOエラー: {}", io_err),
        }
    } else {
        eprintln!("不明なエラー: {:#}", err);
    }
}

注意: ダウンキャストは実行時の型チェックであり、コンパイル時の安全性は保証されません。ダウンキャストが増えてきたら、thiserrorでの型付きエラーへのリファクタリングを検討するサインです。

レイヤードアーキテクチャでエラー戦略を設計する

実際のプロジェクトでは、thiserrorとanyhowをアーキテクチャの層ごとに使い分けるのが定番のパターンです。

各層のエラー型設計

// ---- ドメイン層: thiserror で厳密な型定義 ----
// domain/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DomainError {
    #[error("ユーザーが見つかりません: id={0}")]
    UserNotFound(i64),

    #[error("メールアドレスの形式が不正です: {0}")]
    InvalidEmail(String),

    #[error("残高不足: 必要額={required}, 現在額={current}")]
    InsufficientBalance { required: u64, current: u64 },
}

// ---- インフラ層: thiserror で外部エラーをラップ ----
// infra/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum RepositoryError {
    #[error("データベースエラー")]
    Database(#[from] sqlx::Error),

    #[error("キャッシュエラー")]
    Cache(#[from] redis::RedisError),

    #[error("レコードが見つかりません: テーブル={table}, id={id}")]
    NotFound { table: String, id: i64 },
}

// ---- ユースケース層: thiserror で下位エラーを集約 ----
// usecase/error.rs
use thiserror::Error;
use crate::domain::error::DomainError;
use crate::infra::error::RepositoryError;

#[derive(Error, Debug)]
pub enum UseCaseError {
    #[error(transparent)]
    Domain(#[from] DomainError),

    #[error(transparent)]
    Repository(#[from] RepositoryError),

    #[error("認可エラー: {0}")]
    Unauthorized(String),
}

// ---- アプリケーション層: anyhow で最終的なエラー報告 ----
// main.rs / handler.rs
use anyhow::{Context, Result};

async fn handle_transfer(req: TransferRequest) -> Result<TransferResponse> {
    let result = transfer_use_case
        .execute(req.from, req.to, req.amount)
        .await
        .context("送金処理に失敗しました")?;

    Ok(TransferResponse::from(result))
}

なぜこの設計を選ぶのか:

  • ドメイン層のエラーが型で表現されるため、ユースケース層でパターンマッチによる分岐処理が可能
  • アプリケーション層ではエラーの詳細を意識せず、ログ出力やHTTPレスポンスへの変換に集中できる
  • 新しいエラーバリアントを追加するとコンパイルエラーで未処理パスを検出できる

Webフレームワークとの統合例(Axum)

thiserrorで定義したエラーをHTTPレスポンスに変換するパターンを見てみましょう。

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};

impl IntoResponse for UseCaseError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            UseCaseError::Domain(DomainError::UserNotFound(_)) => {
                (StatusCode::NOT_FOUND, self.to_string())
            }
            UseCaseError::Domain(DomainError::InvalidEmail(_)) => {
                (StatusCode::BAD_REQUEST, self.to_string())
            }
            UseCaseError::Domain(DomainError::InsufficientBalance { .. }) => {
                (StatusCode::CONFLICT, self.to_string())
            }
            UseCaseError::Repository(_) => {
                // 内部エラーの詳細はクライアントに見せない
                (StatusCode::INTERNAL_SERVER_ERROR,
                 "内部サーバーエラーが発生しました".to_string())
            }
            UseCaseError::Unauthorized(_) => {
                (StatusCode::FORBIDDEN, self.to_string())
            }
        };
        (status, message).into_response()
    }
}

よくある間違い: 最初は全層でanyhowを使い、HTTPハンドラでdowncast_refで分岐するアプローチを取りがちです。しかし、この方法ではエラーバリアントの追加時にコンパイラが未処理パスを検出できず、ランタイムで意図しない500エラーが返る原因になります。

選定基準と代替クレートを比較する

thiserror vs anyhow 判定フローチャート

以下のフローで選択するのが実践的です。

エラーハンドリングクレート比較表

観点 thiserror 2.0 anyhow 1.0 snafu 0.8 eyre 0.6
用途 型定義 エラー報告 型定義+コンテキスト エラー報告+表示
Error型サイズ enum依存(24bytes〜) 8 bytes enum依存 8 bytes
生成コスト 約7ns - enum依存 -
バックトレース nightly stable (1.65+) stable stable
コンテキスト追加 フィールドで表現 .context() .context(Selector) .wrap_err()
学習コスト 中〜高
適合場面 ライブラリ、ドメイン層 アプリ、プロトタイプ 大規模システム CLI、ユーザー向け

snafuとeyreという選択肢

thiserrorとanyhowだけでは対応が難しいケースもあります。

snafu: GreptimeDB(時系列データベース)などの大規模プロジェクトで採用されています。同一のソースエラー型(例: std::io::Error)を異なるコンテキストで区別できるのが強みです。thiserrorの#[from]は同一型に1つしか付けられませんが、snafuではコンテキストセレクターで複数のバリアントを作れます。

// snafu の例: 同一の io::Error を読み込みと書き込みで区別
use snafu::{ResultExt, Snafu};

#[derive(Debug, Snafu)]
enum StorageError {
    #[snafu(display("ファイル読み込み失敗: {path}"))]
    ReadFailed {
        path: String,
        source: std::io::Error,  // source フィールドで自動チェーン
    },

    #[snafu(display("ファイル書き込み失敗: {path}"))]
    WriteFailed {
        path: String,
        source: std::io::Error,  // 同一型でも別バリアントとして区別
    },
}

fn read_file(path: &str) -> Result<Vec<u8>, StorageError> {
    std::fs::read(path).context(ReadFailedSnafu { path })
}

eyre: anyhowのフォークで、エラーレポートのカスタマイズに対応しています。CLIツールでcolor-eyreと組み合わせると、色付きのエラーチェーン表示が得られます。

トレードオフ: snafuは表現力が高い反面、ボイラープレートが多く学習コストも高くなります。プロジェクトの規模とチームの習熟度を考慮して選択してください。小〜中規模であればthiserror + anyhowで十分対応可能です。

よくある問題と解決方法

問題 原因 解決方法
#[from]が同一型に複数使えない From<T>は1つの型に対して1つしか実装できないRustの制約 #[source]を使い、手動で変換関数を実装する
anyhowのエラーがSend + Syncを満たさない 内部エラーがSend/Syncを実装していない anyhow::ErrorSend + Syncを要求するため、該当エラーを.to_string()で変換するか、thiserrorでSend + Syncなラッパーを定義する
バックトレースが表示されない 環境変数が未設定 RUST_BACKTRACE=1を設定する。anyhowは自動キャプチャ(Rust 1.65+)
thiserror 2.0への移行でコンパイルエラー フォーマット文字列の挙動変更 {0}の使い方を確認し、暗黙キャプチャを明示的なフィールド参照に置き換える
エラーチェーンが長すぎてログが読みにくい コンテキスト追加が過剰 各層で追加するコンテキストを「何を試みたか」に限定する。内部の詳細はsource()チェーンに任せる

まとめと次のステップ

まとめ:

  • thiserrorErrorトレイトの実装を自動生成するderiveマクロで、型安全なエラー定義に使う。ライブラリやドメイン層に適している
  • anyhowはトレイトオブジェクト型のエラーで、コンテキスト追加とエラー伝播の簡素化に使う。アプリケーション層やプロトタイプに適している
  • 両者は排他的ではなく、レイヤードアーキテクチャで組み合わせるのが実践的なパターン
  • thiserror 2.0ではフォーマット文字列がstd準拠に統一され、r#sourceによるオプトアウトが追加された
  • 大規模プロジェクトではsnafu(コンテキスト付きエラー定義)やeyre(カスタマイズ可能なレポート)も選択肢になる

次にやるべきこと:

  • 自分のプロジェクトのエラー型を棚卸しし、thiserror化できる箇所を特定する
  • cargo add thiserror anyhowで両クレートを導入し、小さなモジュールから段階的に適用する
  • Comprehensive Rustのエラーハンドリングの章で公式推奨パターンを確認する

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?