Rustには例外(exception)がありません。Java、Python、JavaScriptといった多くの言語が try-catch でエラーを処理するのに対し、Rustは Result<T, E> 型と Option<T> 型でエラーを「値」として扱います。
この設計により、関数の戻り値にエラーの可能性が型として表現され、コンパイル時にエラー処理の漏れを検出できます。本記事では、Result の基礎から thiserror と anyhow の実践的な使い分けまでを順を追って解説します。
Result型とOption型の基本
Result<T, E> は成功値 T またはエラー値 E を持つ列挙型、Option<T> は値の有無を表す列挙型です。
use std::fs;
fn main() {
// Result: エラーの可能性がある処理
match fs::read_to_string("config.toml") {
Ok(content) => println!("内容: {}", content),
Err(e) => eprintln!("読み込み失敗: {}", e),
}
// Option: 値が存在しない可能性がある処理
let v = vec![1, 2, 3];
match v.get(5) {
Some(val) => println!("値: {}", val),
None => println!("インデックス範囲外"),
}
}
unwrapとexpectの使い分け
| メソッド | パニック時の挙動 | 用途 |
|---|---|---|
unwrap() | 汎用メッセージで強制終了 | プロトタイピング、テスト |
expect("msg") | 指定メッセージで強制終了 | 論理的に失敗しない場面 |
match / if let | パニックしない | プロダクションコード |
unwrap() はプロトタイピングでは便利ですが、プロダクションコードでは原則として使うべきではありません。
?演算子によるエラー伝播
? 演算子は Result が Err の場合に即座に呼び出し元へエラーを返します。match のネストを大幅に削減できます。
use std::fs;
use std::io;
fn read_config() -> Result<String, io::Error> {
let content = fs::read_to_string("config.toml")?;
Ok(content)
}
? は複数回連続して使え、失敗しうる処理を直線的に記述できます。
fn load_port() -> Result<u16, io::Error> {
let content = fs::read_to_string("config.toml")?;
let port: u16 = content.trim().parse()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(port)
}
From traitによる自動変換
? 演算子はエラー型が異なる場合に From トレイトで自動変換を試みます。戻り値のエラー型に From<元のエラー型> が実装されていれば、明示的な map_err は不要です。
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}
fn load_port() -> Result<u16, AppError> {
let content = std::fs::read_to_string("config.toml")?; // 自動変換
let port: u16 = content.trim().parse()?; // 自動変換
Ok(port)
}
独自エラー型の定義
ライブラリが複数のエラーを扱う場合、enum で独自エラー型を定義し、Display と Error トレイトを実装します。
use std::fmt;
#[derive(Debug)]
enum ConfigError {
IoError(std::io::Error),
ParseError(std::num::ParseIntError),
MissingField(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::IoError(e) => write!(f, "IO error: {}", e),
ConfigError::ParseError(e) => write!(f, "Parse error: {}", e),
ConfigError::MissingField(s) => write!(f, "Missing field: {}", s),
}
}
}
impl std::error::Error for ConfigError {}
さらに各エラー型への From 実装も必要になり、バリアントが増えるほど保守コストが膨らみます。
thiserrorによるエラー型の簡略化
thiserror はderiveマクロで Display、Error、From の実装を自動生成します。
[dependencies]
thiserror = "2"
先ほどの ConfigError を書き直すと以下のようになります。
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Parse error: {0}")]
ParseError(#[from] std::num::ParseIntError),
#[error("Missing field: {0}")]
MissingField(String),
}
手動実装の数十行が10行以下になりました。主要なアトリビュートは以下のとおりです。
| アトリビュート | 機能 |
|---|---|
#[error("...")] | Display実装を生成 |
#[from] | From実装を生成 |
#[source] | Error::source()の対象を指定 |
#[transparent] | 内包エラーのDisplayとsourceを委譲 |
実践例: HTTPクライアントのエラー型
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiClientError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Failed to deserialize response: {0}")]
Deserialization(#[from] serde_json::Error),
#[error("API returned error status {status}: {message}")]
ApiError { status: u16, message: String },
#[error("Request timeout after {0} seconds")]
Timeout(u64),
}
anyhowによるアプリケーションエラー管理
anyhow はエラーの種類を区別する必要がないアプリケーションコード向けのライブラリです。あらゆるエラー型を anyhow::Error に統一し、コンテキスト情報を付与できます。
[dependencies]
anyhow = "1"
context()によるエラーチェーン
use anyhow::{Context, Result};
fn load_config() -> Result<Config> {
let content = std::fs::read_to_string("config.toml")
.context("config.toml の読み込みに失敗")?;
let config: Config = toml::from_str(&content)
.context("config.toml のパースに失敗")?;
Ok(config)
}
エラー発生時に以下のようなチェーンが表示されます。
Error: config.toml のパースに失敗
Caused by:
expected value at line 3 column 5
bail!とensure!マクロ
use anyhow::{bail, ensure, Result};
fn validate_port(port: u16) -> Result<()> {
ensure!(port >= 1024, "ポート番号 {} は予約済みの範囲です", port);
Ok(())
}
fn connect(host: &str) -> Result<()> {
if host.is_empty() {
bail!("ホスト名が空です");
}
Ok(())
}
ライブラリとアプリケーションの使い分け
thiserror と anyhow は競合するクレートではなく、用途が明確に分かれています。
| 観点 | thiserror | anyhow |
|---|---|---|
| 用途 | ライブラリ・共有モジュール | アプリケーション(main関数側) |
| エラー型 | 明示的なenum | anyhow::Error(型消去) |
| 利用者がmatch可能 | はい | いいえ(downcast必要) |
| コンテキスト付与 | 手動 | .context()で簡単 |
| エラーチェーン表示 | 手動実装 | 自動 |
thiserrorを選ぶケース: ライブラリを書いている、呼び出し側がエラー種別で分岐する必要がある
anyhowを選ぶケース: CLIやWebサーバーなどのアプリケーション、エラーはログやメッセージとして処理される
両方を使うケース(実務で最も多い): ライブラリ層は thiserror、アプリケーション層は anyhow
実務でのエラー設計パターン
実際のプロジェクトでは、レイヤーごとにエラー型を分けて設計します。
アプリケーション層 (anyhow::Result) ← context()でコンテキスト付与
ドメイン層 (thiserror: DomainError) ← #[from]で自動変換
インフラ層 (thiserror: InfraError)
インフラ層とドメイン層のエラー定義
use thiserror::Error;
// インフラ層
#[derive(Debug, Error)]
pub enum InfraError {
#[error("Database query failed: {0}")]
Database(#[from] sqlx::Error),
#[error("HTTP client error: {0}")]
HttpClient(#[from] reqwest::Error),
}
// ドメイン層
#[derive(Debug, Error)]
pub enum DomainError {
#[error("User not found: id={0}")]
UserNotFound(u64),
#[error("Invalid email format: {0}")]
InvalidEmail(String),
#[error(transparent)]
Infrastructure(#[from] InfraError),
}
APIレスポンスへのエラー変換
Webアプリケーションでは、内部のエラー型をHTTPステータスコードに変換する層が必要です。
impl From<DomainError> for HttpResponse {
fn from(err: DomainError) -> Self {
match &err {
DomainError::UserNotFound(_) => {
HttpResponse::NotFound().json(ErrorBody { message: err.to_string() })
}
DomainError::InvalidEmail(_) => {
HttpResponse::BadRequest().json(ErrorBody { message: err.to_string() })
}
DomainError::Infrastructure(_) => {
log::error!("{:?}", err);
HttpResponse::InternalServerError()
.json(ErrorBody { message: "Internal Server Error".into() })
}
}
}
}
このパターンにより、インフラ層の詳細なエラー情報がAPIレスポンスに漏洩するのを防ぎつつ、内部ログには完全なエラーチェーンを残せます。
よくある質問(FAQ)
Q1: unwrap()をプロダクションコードで使ってよい場面はありますか?
論理的に Err / None が発生しないことが保証できる場面では許容されます。たとえば、コンパイル時に確定する正規表現のパースなどです。その場合でも expect("理由") で意図を明示するのが推奨されます。
Q2: thiserrorとanyhowを同じクレート内で併用しても問題ありませんか?
問題ありません。むしろ実務では最も一般的なパターンです。ドメインロジックやインフラ層では thiserror で型付きエラーを定義し、main 関数やAPIハンドラでは anyhow でエラーを集約します。
Q3: Boxとanyhow::Errorの違いは何ですか?
どちらも型消去する点は同じですが、anyhow::Error は context() によるエラーチェーン構築、backtrace のキャプチャ、downcast_ref による元のエラー型の取り出し、bail! / ensure! マクロを備えています。アプリケーションコードでは anyhow を使うのが一般的です。
Q4: パニックとResultはどう使い分けるべきですか?
パニックは「回復不能なバグ」(配列の範囲外アクセス、不変条件の違反など)、Result は「想定される失敗」(ファイル不在、ネットワーク切断など)に使います。実行時に起こりうる事象は Result で処理するのが原則です。
まとめ
Rustのエラーハンドリングは、例外ベースの言語とは根本的に異なるアプローチを取っています。
- Result型とOption型でエラーと値の不在を型として表現する
- ?演算子でエラー伝播のボイラープレートを削減する
- thiserrorでライブラリ向けの型安全なエラー型を簡潔に定義する
- anyhowでアプリケーション層のエラーを柔軟に集約する
- レイヤーごとにエラー型を分離し、Fromトレイトで変換する
まずは thiserror と anyhow を Cargo.toml に追加し、小さなプロジェクトで試してみてください。コンパイラがエラー処理の漏れを指摘してくれる安心感は、一度体験すると手放せなくなります。