コンテンツにスキップ

Go の errors パッケージ設計史: error chain から error tree へ

このテーマの狙い

errors パッケージの使い方紹介ではなく、標準ライブラリとしての設計判断を読む。

話の中心はこれ。

Go の error は、どこまで「中身をたどれる構造」として標準 API に露出すべきなのか。

この切り口にすると、既存の errors.Is / errors.As / errors.Join 入門とは被りにくい。 一方で、errors.Join の使い方だけに寄せると既存発表とかなり被る。

まず押さえる軌跡

時期 追加・議論 見るべきもの 意味
Go 1.13 Unwrap() error, errors.Is, errors.As, %w Working with Errors in Go 1.13, Error Inspection proposal error chain を標準化
Go 1.20 errors.Join, Unwrap() []error, 複数 %w #53435 chain から tree へ
Go 1.20 後 errors.Errors 的な分解 API #57358 Join したなら取り出したい問題
Go 1.24 以降の議論 Iter / IterAs #66455 Is / As では「全部見たい」に弱い
Go 1.26 errors.AsType #51945 As の ergonomics 改善

Go 1.13: error chain の標準化

Go 1.13 以前も、os.PathError のように「別の error を内包する error」は存在していた。 ただし標準的な取り出し方はなかった。

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return e.Query + ": " + e.Err.Error()
}

呼び出し側が中身を見たいなら、具象型を知っている必要があった。

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // 権限エラーとして扱う
}

Go 1.13 で Unwrap() error という規約が入り、errors.Is / errors.As が error chain を探索するようになった。

func (e *QueryError) Unwrap() error {
    return e.Err
}

if errors.Is(err, ErrPermission) {
    // err 自体か、err が wrap しているどこかに ErrPermission がある
}

ここで重要なのは、Unwrap は単なるデバッグ用ではないこと。 errors.Is / errors.As がプログラム上の判断に使うための構造を作る。

ここでの設計判断

fmt.Errorf("%w", err) で wrap すると、内側の error は API 契約の一部になる。

例えば、DB 実装の都合で sql.ErrNoRows が出たときに %w で返すと、呼び出し側はこう書ける。

if errors.Is(err, sql.ErrNoRows) {
    // not found
}

これは便利だが、パッケージ実装を将来差し替えにくくする。 つまり error の中身を露出することは、抽象化境界を露出することでもある。

発表ではここを強調するとよい。

wrap は「詳しいログを残す」ではなく、「呼び出し側に判定可能性を公開する」行為。

Go 1.20: error chain から error tree へ

Go 1.13 の Unwrap() error は直線的な chain だった。

handler error
  -> service error
    -> sql.ErrNoRows

しかし、複数の処理結果をひとつの error として返したい場面がある。

  • 入力バリデーションで複数フィールドが失敗した
  • 複数 goroutine の結果をまとめたい
  • 処理本体の error と Close の error を両方返したい

Go 1.20 では errors.JoinUnwrap() []error が入った。

err := errors.Join(errA, errB)

この時点で、error は chain ではなく tree になる。

joined error
  -> errA
  -> errB

errors.Is / errors.As は、この tree を深さ優先で見る。

なぜ難しいか

複数 error をまとめるだけなら簡単に見える。 しかし標準ライブラリでは次の契約を決める必要がある。

  • Unwrap() errorUnwrap() []error を同名にしてよいか
  • 両方実装した型はどう扱うか
  • errors.UnwrapUnwrap() []error を返すべきか
  • errors.Is は複数のうち1つでも一致すれば true でよいか
  • errors.As は複数一致するとき、どれを返すか
  • errors.Join はネストを flatten すべきか
  • error の表示順を仕様としてどこまで固定するか

ここが「標準パッケージとしての OSS」の面白いところ。 便利 API ではなく、エコシステム全体の互換ルールを決める話になる。

Join したなら分解したい問題

errors.Join が入ると自然にこう思う。

errs := errors.Errors(err)
for _, e := range errs {
    // 個別に処理したい
}

実際に #57358 では、errors.Join を戻す errors.Errors のような API が提案された。

この要望は自然。 Join できるなら、取り出せる API も欲しくなる。

ただし標準 API としては悩ましい。

func Errors(err error) []error

としたとき、次を決める必要がある。

  • plain error の場合は []error{err} を返すのか、nil を返すのか
  • 直下の子だけ返すのか、tree 全体を flatten するのか
  • nil error をどう扱うのか
  • 返した slice は変更してよいのか
  • fmt.Errorf の複数 %werrors.Join の違いをどこまで見せるのか

つまり「分解 API」は小さく見えて、error tree の構造を標準 API の契約にする。 ここが重い。

Iter / IterAs: 全部見たい問題

errors.As は最初に見つかった型だけを返す。

var e *CodeError
if errors.As(err, &e) {
    switch e.Code {
    case CodeFoo:
    case CodeBar:
    }
}

しかし error tree の中に *CodeError が複数あるかもしれない。 この場合、最初の1つだけ見ても十分とは限らない。

#66455 は、error tree を iterator として走査する API を提案している。

for e := range errors.Iter(err) {
    // tree 内の error を順に見る
}

for e := range errors.IterAs[*CodeError](err) {
    // tree 内の *CodeError を全部見る
}

この提案の面白さは、errors.Is / errors.As の限界を認めているところ。 ただし、これも標準 API として入れると「error tree の走査順」をより明示的に契約することになる。

アンチ視点: 全部たどれることは本当に良いのか

error の中身を全部たどれる API があると便利そうに見える。 しかしアンチ的には、これは危うい。

理由は、error が「履歴」「ログ」「トレース」の代用品になりやすいから。

Go の errors が本来強いのは、次のような判定可能性。

errors.Is(err, fs.ErrNotExist)
errors.As(err, &pathErr)

一方で、error tree 全体をユーザーが自由に巡回し始めると、次の設計が曖昧になる。

  • どの wrapper が API 契約で、どれが実装詳細なのか
  • どの順序で見えるべきなのか
  • 同じ error が複数回現れたらどうするのか
  • ログに出すべき情報と、プログラムが分岐すべき情報の境界はどこか

つまり、発表の対立軸はこう置ける。

ユーザー視点:
  Join できるなら全部取り出したい。

標準ライブラリ視点:
  全部取り出せる API は、error tree の構造を標準契約にしてしまう。

この対立が errors パッケージの深掘りポイント。

errors.AsType は補助テーマ

Go 1.26 の errors.AsType は、errors.As の generic 版。

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    // ...
}

をこう書ける。

if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
    // ...
}

これは使いやすいが、主役にすると Go 1.26 新機能紹介と被りやすい。 発表では、errors.As の awkwardness が Go 1.13 時点から残っていて、Go 1.26 で ergonomic に改善された、という補助線として使うのがよい。

発表構成案

タイトル案

Go の errors パッケージはなぜ error tree を採用したのか

10分構成

  1. Go 1.13 以前: error は値だが、内側の error を見る標準手段がなかった
  2. Go 1.13: Unwrap, Is, As, %w で error chain が標準化された
  3. wrap はログではなく API 契約である
  4. Go 1.20: errors.Join で chain から tree へ
  5. 複数 error を標準化すると、分解・順序・flatten・探索の契約が問題になる
  6. errors.Errors / Iter proposal が出る理由
  7. アンチ視点: 全部たどれる API は error を履歴装置にしてしまう
  8. まとめ: errors は「失敗の履歴」ではなく「判定可能性」を標準化している

参考資料