Rustには例外(exception)がありません。Java、Python、JavaScriptといった多くの言語が try-catch でエラーを処理するのに対し、Rustは Result<T, E> 型と Option<T> 型でエラーを「値」として扱います。

この設計により、関数の戻り値にエラーの可能性が型として表現され、コンパイル時にエラー処理の漏れを検出できます。本記事では、Result の基礎から thiserroranyhow の実践的な使い分けまでを順を追って解説します。

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() はプロトタイピングでは便利ですが、プロダクションコードでは原則として使うべきではありません。

?演算子によるエラー伝播

? 演算子は ResultErr の場合に即座に呼び出し元へエラーを返します。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 で独自エラー型を定義し、DisplayError トレイトを実装します。

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マクロで DisplayErrorFrom の実装を自動生成します。

[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(())
}

ライブラリとアプリケーションの使い分け

thiserroranyhow は競合するクレートではなく、用途が明確に分かれています。

観点thiserroranyhow
用途ライブラリ・共有モジュールアプリケーション(main関数側)
エラー型明示的なenumanyhow::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::Errorcontext() によるエラーチェーン構築、backtrace のキャプチャ、downcast_ref による元のエラー型の取り出し、bail! / ensure! マクロを備えています。アプリケーションコードでは anyhow を使うのが一般的です。

Q4: パニックとResultはどう使い分けるべきですか?

パニックは「回復不能なバグ」(配列の範囲外アクセス、不変条件の違反など)、Result は「想定される失敗」(ファイル不在、ネットワーク切断など)に使います。実行時に起こりうる事象は Result で処理するのが原則です。

まとめ

Rustのエラーハンドリングは、例外ベースの言語とは根本的に異なるアプローチを取っています。

  • Result型Option型でエラーと値の不在を型として表現する
  • ?演算子でエラー伝播のボイラープレートを削減する
  • thiserrorでライブラリ向けの型安全なエラー型を簡潔に定義する
  • anyhowでアプリケーション層のエラーを柔軟に集約する
  • レイヤーごとにエラー型を分離し、Fromトレイトで変換する

まずは thiserroranyhowCargo.toml に追加し、小さなプロジェクトで試してみてください。コンパイラがエラー処理の漏れを指摘してくれる安心感は、一度体験すると手放せなくなります。