🍣

Goのfmt.Errorf(%w)でラップすべきか否かの基準

2023/01/29に公開

今回fmt.Errorf()メソッドを使用するときに%wを使って呼び出し元にラップされたエラーを返すような実装をよく見かける。
たくさんの記事でラップするメリットを読み、なるほど〜と納得いったのだが
個人的に気になったのがラップするべきかどうかの基準はどこにあるのだろうかという話
調べたことをまとめる

結論

ラップするかどうかは文脈による。身も蓋もない話
将来他のパッケージから返ってくるエラーが特定のエラーという保証がないのなら、根本的なエラーを公開するべきではないとのこと

When adding additional context to an error, either with fmt.Errorf or by implementing a custom type, you need to decide whether the new error should wrap the original. There is no single answer to this question; it depends on the context in which the new error is created. Wrap an error to expose it to callers. Do not wrap an error when doing so would expose implementation details.
https://go.dev/blog/go1.13-errors 「Whether to Wrap」

この根本的なエラーとは例えばdatabase/sqlパッケージのsql.ErrNoRowsだったり、そのパッケージ内で定義されているエラーのこと。これが今後も変更されないことが保証されていないなら安易にラップすべきではないということ。例えば、dbにアクセスするメソッドの返り値のエラーにsql.ErrNoRowsをラップしていることがわかっていると、呼び出し側はerrors.Isなどで比較する際にsql.ErrNoRowsを使用して比較することになる。これ自体は問題がないのだが、仮にsql.ErrNoRowssql.ErrRowsに変わってしまうと呼び出し側で予期せぬバグが発生しかねないという話

具体例

具体例を混ぜる。
下のコードはfuga()という自前のメソッド内で別パッケージのメソッドを実行し、返ってきたエラーをラップし、
呼び出し元のerrors.Isメソッドで比較している。

fuga()メソッドで得られるエラーは%wを渡すことで依存先のエラーをUnwrapすることを許可している。
これによって外部パッケージの内部の実装をより詳細に知ることができる。
代わりに、errors.Isメソッドで依存先のパッケージで定義されているエラーを引っ張ってくる必要があり、呼び出し側に依存の強制を強いることになる。

// 自前でエラーを定義
var ErrRecordNotFound = errors.New("record not found")
var ErrDuplicateFound = errors.New("duplicate")

func main() {
	err := fuga()
	if err != nil {
		if errors.Is(err, ErrRecordNotFound) {
			fmt.Println("ErrRecordNotFound:", err)
		} else if errors.Is(err, ErrDuplicateFound) {
			fmt.Println("ErrDuplicateFound:", err)
		} else if errors.Is(err, pkg.ErrPkg) {
			fmt.Println("ErrPkg:", err)
		}
	}
}

func fuga() error {
	err := pkg.Process()
	if err != nil {
		return fmt.Errorf("fuga func: %w", err)
	}
	return nil
}


package pkg

import "errors"

var ErrPkg = errors.New("pkg error")

func Process() error {
	return ErrPkg
}

対策とか

At that point, the function must always return sql.ErrNoRows if you don’t want to break your clients, even if you switch to a different database package. In other words, wrapping an error makes that error part of your API. If you don’t want to commit to supporting that error as part of your API in the future, you shouldn’t wrap the error.
It’s important to remember that whether you wrap or not, the error text will be the same. A person trying to understand the error will have the same information either way; the choice to wrap is about whether to give programs additional information so they can make more informed decisions, or to withhold that information to preserve an abstraction layer.

これはあくまでケースバイケースであり、結局のところ設計者の意図によって変わる。
依存先のパッケージがどうなろうとも受け入れる体制を取っていれば、%wを設定することもあると思う。

外部のパッケージのエラーは取得しつつ、呼び出し元のerrors.Isメソッドなどで条件分岐をしたいケースの場合はどうすれば良いのか

こんなのあるんじゃねケース

  1. 外部のエラーを%vで設定し、自前で定義したエラーを%wで返す
    パッケージのエラーは文字列で返しつつ、自前で定義したエラーで条件分岐をする
    こうすることで抽象度はキープされる。pkg.ErrPkgをimportしなくていい
var ErrRecordNotFound = errors.New("record not found")
var ErrDuplicateFound = errors.New("duplicate")

func main() {
	err := hoge()
	if err != nil {
		if errors.Is(err, ErrRecordNotFound) {
			fmt.Println("errorsIs: ErrRecordNotFound:", err)
		} else if errors.Is(err, ErrDuplicateFound) {
			fmt.Println("errorsIs: ErrDuplicateFound:", err)
		}
	}
}

func hoge() error {
	err := pkg.Process()
	if err != nil {
		return fmt.Errorf("hoge func: %w, %v", ErrRecordNotFound, err)
	}
	return nil
}
// OutPut
// errorsIs: ErrRecordNotFound: hoge func: record not found, pkg error

感想とまとめ

勉強するきっかけはGOらしい書き方を勉強中にerrorsパッケージについて少しだけ興味を持って調べ出し、公式を読み出したとき。
公式のドキュメント読み終わった後に再度情報収集も含めていろんな方の記事を読むとよく頭に入る。

処理を1つずつ追いながら説明もらえるので、学習しやすくてよかった動画
errorsパッケージを読む - tenntenn.go#1

参考文献

Discussion