じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

`thiserror` と `#[non_exhaustive]` で SemVer に強いエラー型を作る

はじめに

Rust で「ライブラリのエラー型をどう設計するか」は永遠のテーマです。素朴な答えは2つあります。

  • アプリケーションなら anyhow で済ませる
  • ライブラリなら thiserror で型付きエラーを返す

これは2026年でも変わりません。この記事は「ライブラリ向け」のほうの設計を、 #[non_exhaustive] を組み合わせて SemVer 互換を壊さない形で書く方法を扱います。

まず動くコード

use std::num::ParseIntError;

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoadError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),

    #[error("parse: {0}")]
    Parse(#[from] ParseIntError),

    #[error("empty input")]
    Empty,
}

pub fn parse_first_number(text: &str) -> Result<i64, LoadError> {
    let line = text.lines().next().ok_or(LoadError::Empty)?;
    let n = line.trim().parse::<i64>()?; // ParseIntError → LoadError 自動変換
    Ok(n)
}

fn main() -> Result<(), LoadError> {
    println!("{}", parse_first_number("42\nrest")?);
    println!("{:?}", parse_first_number(""));
    println!("{:?}", parse_first_number("not-a-number"));
    Ok(())
}

実行結果は次のとおりです。

42
Err(Empty)
Err(Parse(ParseIntError { kind: InvalidDigit }))

Cargo.toml に必要なものは1行だけです。

[dependencies]
thiserror = "2"

thiserror 2.0 は 2024年末リリースで、Rust 2021/2024 両方で動きます。

thiserror の役割

thiserror::Error#[derive] すると以下が自動で実装されます。

  • std::error::Error (=Rust の標準エラートレイト)
  • std::fmt::Display (#[error("...")] 属性で書式を指定)
  • From<T> (#[from] 属性を付けたフィールドから自動変換)

書く側は Debug#[derive] して、各 variant に #[error("メッセージ")] を付けるだけ。

#[derive(Debug, thiserror::Error)]
pub enum MyError {
    #[error("bad request: {reason}")]
    BadRequest { reason: String },
}

{reason} のような placeholder には struct のフィールド名や、 タプル variant のインデックス ({0}, {1}) を使えます。

#[from] でエラー変換を ? で連鎖させる

#[from]? 演算子で自動変換するための From 実装 を生成します。これにより、内部エラーを呼び出し側に透過的に伝搬できます。

#[derive(Debug, thiserror::Error)]
pub enum LoadError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
}

fn read() -> Result<String, LoadError> {
    let s = std::fs::read_to_string("config.toml")?; // io::Error → LoadError::Io
    Ok(s)
}

? を書くだけで std::io::ErrorLoadError::Io(...) に変換されます。手書きの map_err は要りません。

注意点があります。同じ型を #[from] 付きで複数の variant に置くことはできません。「io::ErrorIoDisk の両 variant に入れたい」ならどちらかは手書きの From 実装にします。

#[non_exhaustive] で SemVer を守る

ここが重要です。公開ライブラリのエラー型に #[non_exhaustive] を付けると、利用者の match 文に _ ワイルドカード arm を強制できます

// あなたのライブラリ側
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoadError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("empty input")]
    Empty,
}

利用者側の match はこうなります。

// 利用者側
match error {
    LoadError::Io(_) => println!("io error"),
    LoadError::Empty => println!("empty"),
    _ => println!("other"),  // ← この `_` がないとコンパイルエラー
}

_ を強制することで、あなたのライブラリが 将来 variant を追加しても利用者のコードは壊れません。これが #[non_exhaustive] の最大のメリットです。新しい variant の追加は SemVer 上「破壊的変更」になりえますが、#[non_exhaustive] 付きなら minor バージョン (1.0 → 1.1) で追加できます。

注意点を2つ挙げます。

  • ライブラリ内部 (同じ crate 内) では _ を強制されない。crate 外からの match だけ制約される
  • struct でも使える (新フィールド追加に強くなる)

ライブラリ vs アプリの使い分け

ここで最初の二択に戻ります。

用途 選択 理由
公開ライブラリ thiserror で型付きエラー 利用者がエラー種別ごとにハンドリングできる
アプリ (CLI, サーバー) anyhow で型消去 エラーを上位に投げて main で ? するだけで済む
内部 lib (同じ workspace 内) thiserror 推奨 後で公開する可能性 / テストでエラー種別を assert したい

anyhow を使ったコードを公開ライブラリにする」のが一番こじれます。利用者は anyhow::Error を見せられても何もできません。最初から thiserror で型を切るほうが、後で楽になります。

エラー設計のチェックリスト

公開ライブラリのエラー enum を書くとき、以下を確認します。

  • [ ] #[derive(Debug, thiserror::Error)] が付いている
  • [ ] #[non_exhaustive] が付いている
  • [ ] 各 variant に #[error("...")] で人間向けメッセージがある
  • [ ] 内部から伝搬したいエラーに #[from] を付けている
  • [ ] エラーメッセージに値が含まれる場合、placeholder で書式指定している
  • [ ] Result<T, MyError> を返す関数のドキュメントに、どの variant がいつ発生するか書いてある

最後の項目はドキュメント側ですが、利用者にとっては最も大事です。「Foo::do_itMyError::Empty を返すことがあります」と書いておくと、利用者は match で網羅できます。

まとめ

  • ライブラリのエラー型は thiserror::Error#[derive] する
  • #[error("...")]Display 出力を、#[from]From 実装を自動生成
  • #[non_exhaustive] を付けると、利用者の match に _ arm が強制され、 minor バージョンで variant を追加できる
  • アプリ層は anyhow で型消去するのが楽。公開ライブラリは thiserror

thiserror + #[non_exhaustive] の2つは、新規ライブラリ初日に入れる組み合わせとしてほぼ正解です。

関連