Go の自作エラーを errors.Is と errors.As で wrap 元のエラーと識別するときには、Unwrap も実装しよう
概要
Go ではデフォルトのエラーに対して、自作エラーを作成して wrap するように有識者の間では推奨されています。
ただ、wrap すると本来のエラーが隠れてしまいテストや実行時に単純な比較ができなくなります。
そこで、よく紹介されるのが、errors.Is
とerrors.As
です。
erros.Is
とerrors.As
を使うと wrap 済みエラーに対して、元のエラーとの比較できます。
しかし、紹介記事では、そのままコピー&ペーストするとtrue
を返してほしいときに、false
を返してしまう方法を散見します。
本記事では、間違えてしまうパターンについて紹介し、解決方法を紹介します。
元のエラーが不要で、wrap したエラーのみで比較する実装のときには、本記事の手順は不要になります。
うまく比較できないパターン
最初に、うまく比較できないパターンがどのようなときに発生するのか紹介します。
以下は、fmt.Errorf
を用いてエラーを wrap した実装です。
errors.Is
とerrors.As
の結果は問題なくtrue
を返していることがわかります。
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
if _, err := os.Open("non-existing"); err != nil {
var pathError *fs.PathError
wrapedErr := fmt.Errorf("err is %w", err)
if errors.As(wrapedErr, &pathError) {
fmt.Println("errors.As():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
if errors.Is(wrapedErr, pathError) {
fmt.Println("errors.Is():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
}
}
// Output
// errors.As():Failed at path: non-existing
// errors.Is():Failed at path: non-existing
続いて、自作エラーで wrap したパターンです。以下の実装ではうまくいきません。
一見、問題なく見えますが、errors.Is
とerrors.As
がfalse
を返していることがわかります。
なぜ、このようになるのでしょうか。
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
type SampleError struct {
message string
err error
}
func (se *SampleError) Error() string {
return se.message
}
func main() {
if _, err := os.Open("non-existing"); err != nil {
var pathError *fs.PathError
wrapedErr := &SampleError{message: "this is wraped err", err: err}
if errors.As(wrapedErr, &pathError) {
fmt.Println("errors.As():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
if errors.Is(wrapedErr, pathError) {
fmt.Println("errors.Is():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
}
}
// open non-existing: no such file or directory
// open non-existing: no such file or directory
解決策
前述の問題に対する、解決策を紹介します。
タイトル通り、自作エラーにUnwrap
を実装する必要があります。
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
type SampleError struct {
message string
err error
}
func (se *SampleError) Error() string {
return se.message
}
func (se *SampleError) Unwrap() error { // 追加
return se.err
}
func main() {
if _, err := os.Open("non-existing"); err != nil {
var pathError *fs.PathError
wrapedErr := &SampleError{message: "this is wraped err", err: err}
if errors.As(wrapedErr, &pathError) {
fmt.Println("errors.As():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
if errors.Is(wrapedErr, pathError) {
fmt.Println("errors.Is():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
}
}
// Output:
// errors.As():Failed at path: non-existing
// errors.Is():Failed at path: non-existing
問題に対する解決策は以上になります。
以降は、なぜUnwrap()
が必要なのか解説します。
errors.Is、errors.As
この間違いの原因にはerrors.Is
とerrors.As
の特徴を確認する必要があります。
以下の表に違いをまとめました。
基本的には、エラーの比較方法は 2 あり、false を返す方法は共通であることがわかります。
いろいろ書きましたが、Unwrap できなくなるまで実行されていることが重要です。
errors.Is
と errors.As
の実装の詳細を確認します。
errors.Is | errors.As | |
---|---|---|
第 1 引数(err) | Error | Error |
第 2 引数(target) | Error | any(interface{}) |
比較方法 1 | エラーのインスタンスが同一 | エラーの型に代入可能 |
true を返す条件 1 | isComparable(比較可能)かつ err と target のインスタンスが同じ | target の型に err を代入できるとき |
比較方法 2 | err.Is()を使う | err.As()を使う |
true を返す条件 2 | err にIs() が実装されている。Is(target)が true |
err にAs() が実装されている。As(target) が true |
false を返す条件 | Unwrap できなくなるまで | Unwrap できなくなるまで |
panic になる条件 | erros.Is()内にはない | target に不正なデータが含まれるとき |
errors.Is
以下が、errors.Is
の実装です。
表にまとめた通りの実装になっていることがわかります。
Unwrap
からnil
が帰ってきたら、false
を返して終了します。
func Is(err, target error) bool {
if target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable() // 比較可能について確認
for {
if isComparable && err == target { // isComparable(比較可能)且つ err と target のインスタンスが同じ
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) { // Is メソッドが実装されているなら、実行して結果を確認する
return true
}
if err = Unwrap(err); err == nil { // Unwrapする。Unwrapできなければ false を返して終了
return false
}
}
}
ちなみにですが、Is()
メソッドは以下のようなときに使用できます。
wrap したエラーと比較するエラーのインスタンスが異なるときです。
具体的にはvalidator.ValidationErrors
とNewSample
のバリデーション結果はエラーのインスタンスが異なるため、errors.Is()
ではtrue
を返しません。
そこで、構造体WrappedSample
に新しくIs
メソッドを追加し、validator.ValidationErrors
のときtrue
を返すようにすると動作します。
しかし、このような場合は後述するerrors.As()
(以下のソースコードのコメントアウトしてある)を使用したほうが早かったりもします。
package main
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
)
type Sample struct {
NaturalNumber int `validate:"gt=0"`
Word string `validate:"gte=5"`
}
func validateSample(s *Sample) error {
validate := validator.New()
return validate.Struct(s)
}
func NewSample(aNaturalNumber int, aWord string) (*Sample, error) {
sample := &Sample{NaturalNumber: aNaturalNumber, Word: aWord}
if err := validateSample(sample); err != nil {
return nil, &WrappedSample{message: "this is wrapped", err: err}
}
return sample, nil
}
type WrappedSample struct {
message string
err error
}
func (wf *WrappedSample) Error() string {
return wf.message
}
func (wf *WrappedSample) Unwrap() error {
return wf.err
}
func main() {
_, err := NewSample(-1, "word")
if err != nil {
// var verrs1 validator.ValidationErrors
// if errors.As(err, &verrs1) {
// for _, verr := range verrs1 {
// fmt.Println(verr)
// }
// }
var verrs2 validator.ValidationErrors
if errors.Is(err, &verrs2) {
for _, verr := range verrs2 {
fmt.Println(verr)
}
}
}
}
errors.As
以下が、errors.As
の実装です。
こちらも表にまとめた通りの処理が実装していることがわかります。
最後の for 文でUnwrap
の戻り値がnil
だったら終了(err == nil
)します。
errors.Is
とerrors.As
がfalse
を返すときは、Unwrap
できなくなったとき、ということがわかりました。
Unwrap
の実装についてみていきます。
func As(err error, target any) bool {
if target == nil { // nil だったら panic
panic("errors: target cannot be nil")
}
val := reflectlite.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflectlite.Ptr || val.IsNil() { // pointer が nil だったら panic
panic("errors: target must be a non-nil pointer")
}
targetType := typ.Elem()
if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) { // インタフェース以外でエラータイプを実装していなかったら panic
panic("errors: *target must be interface or implement error")
}
for err != nil {
if reflectlite.TypeOf(err).AssignableTo(targetType) { // target に err を代入可能だったら、代入してエラーを返す
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
return true
}
err = Unwrap(err)
}
return false
}
Unwrap
以下が errors に実装されている Unwrap()
の処理です。
err
にUnwrap()
が実装されてなければnil
を返し、実装されていればUnwrap()
を実行する単純な処理です。
func Unwrap(err error) error {
u, ok := err.(interface {
Unwrap() error
})
if !ok {
return nil
}
return u.Unwrap()
}
ここで、解決策に書いたサンプルコードを確認します。
Unwrap()
では、wrap 対象のエラーを返しているだけです。
fmt.Errorf("%w", err)
はすでにUnwrap()
が実装されているため、記述は不要でした。
errors.Is
とerrors.As
はエラーが一致しなかったとき、Unwrap()
できなくなるまでUnwrap
を繰り返すだけでした。
しかし、自作エラーにUnwrap()
がないので、wrap されているのに unwrap できない状態だったことがわかります。
ですので、タイトル通り「Go の自作エラーに errors.Is と errors.As を使うときには、Unwrap も実装しよう」ということになります。
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
type SampleError struct {
message string
err error
}
func (se *SampleError) Error() string {
return se.message
}
func (se *SampleError) Unwrap() error {
return se.err
}
func main() {
if _, err := os.Open("non-existing"); err != nil {
var pathError *fs.PathError
wrapedErr := &SampleError{message: "this is wraped err", err: err}
if errors.As(wrapedErr, &pathError) {
fmt.Println("errors.As():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
if errors.Is(wrapedErr, pathError) {
fmt.Println("errors.Is():Failed at path:", pathError.Path)
} else {
fmt.Println(err)
}
}
}
// Output:
// errors.As():Failed at path: non-existing
// errors.Is():Failed at path: non-existing
まとめ
自作エラーにerrors.Is
とerrors.As
で比較するには、Unwrap
が必要なことを具体的に紹介しました。
本記事で伝えたかったことは以下です。
- wrap したエラーを wrap 元と比較するには
errors.Is
とerrors.As
を使う -
errors.Is
とerrors.As
はエラーが一致するか、Unwrap()
できなくなるまで続ける - 自作エラーに
Unwrap()
を明示的に実装しないと、errors.Is
とerrors.As
は wrap されていても途中で操作を切り上げる
「Go のエラーは wrap しましょう」はよく見かけますが、Unwrap()
の実装まで言及されている記事は見つからなかったので、本記事にまとめました。
「wrap 対象は比較しなくて、自作エラーの型だけ確認する」といった場合は、今回の処理は不要です。
Unwrap()
を実装しなくてもエラーがでないので、同じことで疑問に思った方の解決策になれば幸いです。
参考
Discussion
Unwrap
を実装するときには「wrap 元のエラーと比較する」という大前提が必要なため、タイトルを修正しました。