はじめに
Rustのエラーハンドリング、標準の Result<T, E> だけだと結構つらい。
そこで登場するのが2大クレート:
- thiserror : カスタムエラー型を作りやすくする
- anyhow : エラー処理を楽にする
「どっち使えばいいの?」という宗教戦争が勃発しがち。
この記事で決着をつけます(つかない)。
目次
標準のエラーハンドリングの問題
問題1:カスタムエラー型が面倒
use std::fmt;
use std::error::Error;
#[derive(Debug)]
enum MyError {
IoError(std::io::Error),
ParseError(std::num::ParseIntError),
Custom(String),
}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::IoError(e) => write!(f, "IO error: {}", e),
MyError::ParseError(e) => write!(f, "Parse error: {}", e),
MyError::Custom(msg) => write!(f, "{}", msg),
}
}
}
impl Error for MyError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
MyError::IoError(e) => Some(e),
MyError::ParseError(e) => Some(e),
MyError::Custom(_) => None,
}
}
}
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self {
MyError::IoError(e)
}
}
impl From<std::num::ParseIntError> for MyError {
fn from(e: std::num::ParseIntError) -> Self {
MyError::ParseError(e)
}
}
長い。 エラー1種類追加するたびにこれを書くのはつらい。
問題2:エラーの伝播が冗長
fn process() -> Result<(), MyError> {
let content = std::fs::read_to_string("file.txt")
.map_err(|e| MyError::IoError(e))?;
let num: i32 = content.trim().parse()
.map_err(|e| MyError::ParseError(e))?;
Ok(())
}
? だけで済ませたい...
thiserrorとは
derive マクロでカスタムエラー型を楽に定義できるクレート。
[dependencies]
thiserror = "1.0"
基本的な使い方
use thiserror::Error;
#[derive(Error, Debug)]
enum MyError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Custom error: {0}")]
Custom(String),
}
これだけで:
-
Displayトレイトが自動実装 -
Errorトレイトが自動実装 -
#[from]でFromトレイトも自動実装
Before vs After
// Before: 約50行
#[derive(Debug)]
enum MyError { ... }
impl fmt::Display for MyError { ... }
impl Error for MyError { ... }
impl From<std::io::Error> for MyError { ... }
impl From<ParseIntError> for MyError { ... }
// After: 約10行
#[derive(Error, Debug)]
enum MyError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
エラーメッセージのカスタマイズ
#[derive(Error, Debug)]
enum ValidationError {
#[error("名前が空です")]
EmptyName,
#[error("年齢 {age} は範囲外です (0-150)")]
InvalidAge { age: i32 },
#[error("ユーザー {id} が見つかりません")]
UserNotFound { id: u64 },
#[error(transparent)] // 内部エラーをそのまま表示
Other(#[from] anyhow::Error),
}
anyhowとは
エラー型を気にせず楽にエラー処理するためのクレート。
[dependencies]
anyhow = "1.0"
基本的な使い方
use anyhow::{Result, Context, anyhow, bail};
fn read_config() -> Result<Config> {
let content = std::fs::read_to_string("config.toml")
.context("設定ファイルの読み込みに失敗")?;
let config: Config = toml::from_str(&content)
.context("設定ファイルのパースに失敗")?;
Ok(config)
}
Result型のエイリアス
// anyhowのResult
type Result<T> = std::result::Result<T, anyhow::Error>;
// 使用例
fn process() -> anyhow::Result<()> {
// 任意のエラー型を返せる
std::fs::read_to_string("file.txt")?;
"not a number".parse::<i32>()?;
Ok(())
}
便利なマクロ
use anyhow::{anyhow, bail, ensure};
fn validate(age: i32) -> anyhow::Result<()> {
// bail!: エラーを返して早期リターン
if age < 0 {
bail!("年齢は0以上である必要があります");
}
// ensure!: 条件を満たさなければエラー
ensure!(age <= 150, "年齢は150以下である必要があります");
// anyhow!: エラー値を作成
if age == 42 {
return Err(anyhow!("42は禁止された年齢です"));
}
Ok(())
}
context でエラーに情報を追加
use anyhow::Context;
fn process_file(path: &str) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("ファイル '{}' の読み込みに失敗", path))?;
// エラー時の出力例:
// Error: ファイル 'config.toml' の読み込みに失敗
//
// Caused by:
// No such file or directory (os error 2)
Ok(())
}
使い分けの基準
ライブラリ → thiserror
// my_lib/src/lib.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum LibraryError {
#[error("接続に失敗: {0}")]
Connection(String),
#[error("タイムアウト")]
Timeout,
}
pub fn connect(url: &str) -> Result<Connection, LibraryError> {
// ...
}
理由:
- ライブラリの利用者がエラーをパターンマッチしたい
- 明確なエラー型を公開APIとして提供すべき
-
anyhow::Errorを返すと、利用者がエラーの種類を判別できない
アプリケーション → anyhow
// my_app/src/main.rs
use anyhow::{Result, Context};
fn main() -> Result<()> {
let config = load_config()
.context("設定の読み込みに失敗")?;
run_server(config)
.context("サーバーの起動に失敗")?;
Ok(())
}
理由:
- アプリケーションはエラーを表示して終了するだけのことが多い
- 異なるライブラリからのエラーを統一的に扱える
- エラーにコンテキストを追加しやすい
判断フローチャート
このコードは...
│
├─ ライブラリである
│ └─ 利用者がエラーをパターンマッチする?
│ ├─ Yes → thiserror
│ └─ No → thiserror(でも将来のために)
│
└─ アプリケーションである
└─ エラーを細かく分類する必要がある?
├─ Yes → thiserror(または両方)
└─ No → anyhow
両方使うパターン
実際のプロジェクトでは 両方使う ことが多い。
// エラー定義(thiserror)
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("設定エラー: {0}")]
Config(String),
#[error("データベースエラー")]
Database(#[from] sqlx::Error),
#[error("その他のエラー")]
Other(#[from] anyhow::Error), // anyhowを内部に持つ
}
// アプリケーションコード(anyhow)
use anyhow::{Context, Result};
fn process() -> Result<(), AppError> {
let data = fetch_data()
.context("データ取得に失敗")?; // anyhowのcontext
save_to_db(data)?; // sqlx::Error → AppError::Database
Ok(())
}
ベストプラクティス
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Custom(String),
}
// カスタムResult型
pub type Result<T> = std::result::Result<T, Error>;
// src/lib.rs
mod error;
pub use error::{Error, Result};
// 使用側
pub fn process() -> Result<Data> {
let content = std::fs::read_to_string("data.json")?;
let data: Data = serde_json::from_str(&content)?;
Ok(data)
}
実践的なエラー設計
パターン1:レイヤーごとにエラー型
// ドメイン層
#[derive(Error, Debug)]
pub enum DomainError {
#[error("ユーザーが見つかりません: {0}")]
UserNotFound(u64),
#[error("権限がありません")]
Unauthorized,
}
// インフラ層
#[derive(Error, Debug)]
pub enum InfraError {
#[error("データベースエラー: {0}")]
Database(#[from] sqlx::Error),
#[error("キャッシュエラー: {0}")]
Cache(#[from] redis::RedisError),
}
// アプリケーション層(統合)
#[derive(Error, Debug)]
pub enum AppError {
#[error(transparent)]
Domain(#[from] DomainError),
#[error(transparent)]
Infra(#[from] InfraError),
}
パターン2:HTTPステータスコードとの対応
use axum::http::StatusCode;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("リソースが見つかりません")]
NotFound,
#[error("認証が必要です")]
Unauthorized,
#[error("リクエストが不正です: {0}")]
BadRequest(String),
#[error("内部エラー")]
Internal(#[from] anyhow::Error),
}
impl ApiError {
pub fn status_code(&self) -> StatusCode {
match self {
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
まとめ
早見表
| 項目 | thiserror | anyhow |
|---|---|---|
| 用途 | カスタムエラー型定義 | 楽なエラーハンドリング |
| 適した場面 | ライブラリ | アプリケーション |
| パターンマッチ | しやすい | しにくい |
| コンテキスト追加 | 手動 | .context() |
| 学習コスト | やや高 | 低 |
チェックリスト
-
ライブラリを書くなら
thiserrorでエラー型を定義 -
アプリケーションなら
anyhowで楽をする - 両方使うのもアリ
-
context()でエラーに情報を追加する習慣をつける
今すぐできるアクション
- 既存プロジェクトのエラー処理を見直す
-
anyhow::Contextでエラーメッセージを改善 - ライブラリを作るときは
thiserrorを検討
結論:「宗教戦争」じゃなくて、用途で使い分ける。
ライブラリ → thiserror、アプリ → anyhow。両方使ってもいい。
みなさんも良いエラーハンドリングライフを!
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!