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)
}
}
エラーバリアントが増えるたびにDisplay、Error、Fromの3つの実装を更新する必要があり、保守コストが高くなります。この課題を解決するのがthiserrorとanyhowです。
thiserrorで型安全なエラー型を定義する
thiserrorは、dtolnay氏が開発するderiveマクロクレートです。#[derive(Error)]を付けるだけで、Display・Error・Fromトレイトの実装を自動生成します。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)] |
Displayとsourceの委譲 |
ラッパー型エラー |
#[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::ErrorはSend + Syncを要求するため、該当エラーを.to_string()で変換するか、thiserrorでSend + Syncなラッパーを定義する |
| バックトレースが表示されない | 環境変数が未設定 |
RUST_BACKTRACE=1を設定する。anyhowは自動キャプチャ(Rust 1.65+) |
| thiserror 2.0への移行でコンパイルエラー | フォーマット文字列の挙動変更 |
{0}の使い方を確認し、暗黙キャプチャを明示的なフィールド参照に置き換える |
| エラーチェーンが長すぎてログが読みにくい | コンテキスト追加が過剰 | 各層で追加するコンテキストを「何を試みたか」に限定する。内部の詳細はsource()チェーンに任せる |
まとめと次のステップ
まとめ:
-
thiserrorは
Errorトレイトの実装を自動生成するderiveマクロで、型安全なエラー定義に使う。ライブラリやドメイン層に適している - anyhowはトレイトオブジェクト型のエラーで、コンテキスト追加とエラー伝播の簡素化に使う。アプリケーション層やプロトタイプに適している
- 両者は排他的ではなく、レイヤードアーキテクチャで組み合わせるのが実践的なパターン
- thiserror 2.0ではフォーマット文字列がstd準拠に統一され、
r#sourceによるオプトアウトが追加された - 大規模プロジェクトではsnafu(コンテキスト付きエラー定義)やeyre(カスタマイズ可能なレポート)も選択肢になる
次にやるべきこと:
- 自分のプロジェクトのエラー型を棚卸しし、thiserror化できる箇所を特定する
-
cargo add thiserror anyhowで両クレートを導入し、小さなモジュールから段階的に適用する - Comprehensive Rustのエラーハンドリングの章で公式推奨パターンを確認する
参考
- thiserror - GitHub: thiserror公式リポジトリ(v2.0.18)
- anyhow - docs.rs: anyhow公式ドキュメント(v1.0.102)
- Rust Error Handling Compared: anyhow vs thiserror vs snafu - DEV Community: 3クレートの詳細比較
- Error Handling for Large Rust Projects - GreptimeDB: 大規模プロジェクトでのsnafu採用事例と仮想スタックトレースの設計
- thiserror, anyhow, or How I Handle Errors in Rust Apps - ShakaCode: 実践的な使い分けパターンと長期的な保守性の考察
- Comprehensive Rust - thiserror and anyhow: Google公式Rustトレーニング教材のエラーハンドリング章
- Rust Error Handling Guide 2025: 2025年のRustエラーハンドリング最新動向
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。