Goでのエラーハンドリング: errors.Is、errors.As、エラーのwrap

1. 概要

Goでは、エラーは通常の値です。関数は呼び出し元にエラーを返し、呼び出し元自身が処理を決定します。エラーがリポジトリ、サービス、ハンドラーのような複数のレイヤーを通過するとき、往々にしてコンテキストを追加しながらも、元のエラーを認識できるようにしたいでしょう。

この記事では、fmt.Errorfの%wでエラーをwrapする方法、errors.Isでエラーをチェック、errors.Asでカスタムエラーを取得、errors.Joinで複数のエラーをマージする方法について説明します。記事の最後にはテストとよくあるエラーについて書かれています。すべての例は標準ライブラリのみを使用し、Go 1.20以降で実行できます。errors.AsTypeはGo 1.26以降が必要です。

2. Sentinel errorと==比較の制限

Goでは、errorは非常に小さいインターフェースです:

type error interface {
	Error() string
}

“名前付き”エラーを定義する最もシンプルな方法はsentinel errorを作成することです。これはパッケージレベルの変数で、呼び出し元が直接比較できます:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("注文が見つかりません")
	}
}
注文が見つかりません

errors.Newは新しいポインタを作成します。したがって、err == ErrOrderNotFoundはerrがその変数そのものである場合にのみ真になります。このアプローチは、エラーにコンテキストを追加し始めるまでうまく機能します。

3. fmt.Errorfと%wでエラーをwrapする

3.1. %wと%vの違い

サービスレイヤーが注文IDを追加でエラーメッセージに記録したいとします:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

各行について説明します:

  • fmt.Errorf(... %w ...)は新しいエラーを作成します。このエラーは、Unwrap() errorメソッドを通じて元のerrへの参照を保持しています。

  • err == ErrOrderNotFoundはfalseを返します。なぜなら、errは今、wrapped errorと呼ばれる異なる値だからです。

  • errors.Isはwrapチェーンを走査し、内部でErrOrderNotFoundを見つけます。

  • errors.Unwrapは1つのレイヤーをアンラップして元のエラーを返します。

%wを%vに変更しても、出力メッセージは変わりません。ただし、errors.Isはfalseを返します。なぜなら、%vはテキスト文字列のみをコピーして、元のエラーを保持しないからです。

3.2. 複数レイヤーのwrapチェーン

各レイヤーは別のレイヤーをwrapできます。errors.Isはエラーがどれだけ深く埋まっていても、元のエラーを見つけることができます:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

エラーメッセージは圧縮されたスタックトレースのように読めます。最も外側のレイヤーは左にあり、根本原因は右にあります。

4. errors.Is: “このエラーはXか”をチェックする

errors.Is(err, target)は、チェーン内のいずれかのエラーが以下のいずれかの条件を満たす場合、trueを返します:

  1. ==による比較でtargetと同じ、または
  2. Is(error) boolメソッドを持ち、そのメソッドがtargetに対してtrueを返す。

2番目の条件により、エラータイプは自分自身が“等価”sentinelであることを宣言できます。例えば、共通エラーErrNotFoundと、顧客向けのより詳細なエラータイプを持つ場合:

var ErrNotFound = errors.New("not found")

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

HTTPハンドラーは404を返すためにerrors.Is(err, ErrNotFound)をチェックするだけです。エラーが注文、顧客、または製品のどこから来たのかを知る必要がありません。

注意: errors.Isがすでにチェーンを走査しているため、method Is内でUnwrapを呼び出さないでください。Method Isは現在のエラーレイヤーのみに答えるべきです。

5. errors.As: 特定の型のエラーを取得する

Sentinelはエラーが何かを教えてくれるだけです。エラーコードやIDなどの追加データが必要な場合、カスタムエラータイプを使用し、errors.Asで取得します。

5.1. Unwrapを持つカスタムエラータイプ

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

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

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

呼び出し元は以下のように処理します:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

押さえるべきポイント:

  • errors.Asはwrapチェーンを走査します。最初の*PaymentError型に割り当て可能なエラーが見つかると、そのエラーをpeに割り当ててtrueを返します。

  • PaymentErrorがUnwrap()を持っているため、errors.Is(err, ErrInsufficientFunds)はErrフィールドの中まで通り抜けることができます。

  • 2番目のパラメータは対象変数へのポインタである必要があります。ここでは&peで、型は**PaymentErrorです。

5.2. ポインタレシーバーの落とし穴

Method Error()は*PaymentErrorで宣言されているため、*PaymentErrorのみがerrorを実装しています。以下のように書くと間違っています:

var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error

go vetはビルド時にこのエラーをキャッチします。CIでgo vet ./...を実行してください。覚えやすいルール:対象変数の型は、返した型と正確に一致する必要があります。&PaymentError{...}を返すなら、対象変数は*PaymentErrorでなければなりません。

5.3. errors.AsType (Go 1.26+)

Go 1.26から、標準ライブラリにより簡潔なジェネリック版が追加されました。事前に変数を宣言する必要がありません:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

このアプローチはコンパイル時に型安全であり、セクション5.2のようなパニックを引き起こすことはできません。プロジェクトがGo 1.26以降に更新されている場合は、これを使用する必要があります。

6. errors.Joinで複数のエラーをマージする

Go 1.20から、1つのエラーが複数の子エラーをwrapできます。典型的なユースケースはデータ検証で、最初のエラーで止めるのではなく、すべてのエラーを一度に報告したい場合です:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

importに"strings"を追加することを忘れずに。結果は3つのことを示しています:

  • errors.Joinはメッセージを改行で連結します。

  • errors.Isとerrors.Asはそれぞれの子エラーをチェックできます。

  • すべてのパラメータがnilまたは空のリストの場合、errors.Joinはnilを返します。したがって、追加でif len(errs) == 0を書く必要はありません。

fmt.Errorfも同じフォーマット文字列内に複数の%wを受け入れることができます。例えばfmt.Errorf("sync order: %w; %w", errDB, errCache)のようにです。結果のエラーは両方のエラーをwrapします。

7. エラー動作のテストを書く

テストするとき、errors.Isまたはerrors.Asを使ってエラーのアイデンティティをチェックするべきです。エラーメッセージ文字列を比較しないでください。以下は、checkout_test.goファイルに配置されるcheckoutのためのテーブル駆動テストです:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "十分な資金がある", balance: 1000, amount: 500},
		{name: "資金が不足している", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("エラーを予期していません、得た: %v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("エラーが*PaymentErrorを含まない: %v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q, 期待 %q", pe.Code, tc.wantCode)
			}
		})
	}
}

テストを実行:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/十分な資金がある
=== RUN   TestCheckout/資金が不足している
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/十分な資金がある (0.00s)
    --- PASS: TestCheckout/資金が不足している (0.00s)
PASS

このテストは、後で誰かがエラーメッセージを変更したり、別のwrapレイヤーを追加したりしても、正常に実行されます。これがメッセージ文字列ではなく、アイデンティティをチェックする理由です。

8. よくあるエラーとwrapしてはいけない時

8.1. err.Error()文字列の比較

if strings.Contains(err.Error(), "not found") { ... } // 壊れやすい

メッセージを1回編集するだけで、または別のエラーが偶然“not found”というフレーズを含んでいるだけで、ロジックが壊れます。sentinelをerrors.Isと組み合わせて使用してください。

8.2. 型付きnil

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... エラーなし ...
	return pe // nilではない!
}

interface errorは、値がnilの型*PaymentErrorを保持しています。interfaceすでに型を持っているため、err != nilは依然としてtrueを返します。エラーがない場合、常に明示的にreturn nilを書いてください。

8.3. Wrapが内部詳細を公開する

sql.ErrNoRowsを%wでwrapするとき、パッケージは呼び出し元がerrors.Is(err, sql.ErrNoRows)をチェックできることを暗黙のうちに保証しています。そのエラーは公開APIの一部になります。後でMongoDBに切り替えると、呼び出し元のコードが壊れます。

パッケージの境界では、より良い2つの選択肢があります:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // ドメインエラーに翻訳
}
return fmt.Errorf("find order %d: %v", id, err) // メッセージのみを保持し、元のエラーを隠す

注意: %wはAPIの決定であり、単なるフォーマットではありません。呼び出し元が実際に基になるエラーをチェック可能にしたい場合にのみwrapしてください。

8.4. logして同時にreturnする

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

すべてのレイヤーでこれを行うと、1つのエラーがログに複数回表示されます。一般的なルールは処理する(log)か返すのいずれかであり、両方を行わないでください。ロギングは最上位レイヤー(HTTPハンドラーまたはmain関数など)が担当すべきです。

8.5. 繰り返されるコンテキスト、情報の欠落

failed to load order: failed to query: failed to ...のようなメッセージは長くて何も追加されていません。loadOrder id=7: ...のように操作の名前と重要なデータを記録してください。エラーなので“failed”を省略してください。

8.6. errors.Asを使用してはいけない時

エラーの種類を知るだけで良い場合、sentinelを使ったerrors.Isで十分で、より簡単です。呼び出し元が本当にエラーコード、無効なフィールド、リトライ時間などの付随データが必要な場合にのみ、カスタムエラータイプを作成してください。

9. 結論

Goのエラーハンドリングツールセットを見てきました:

  • fmt.Errorfと%wはコンテキストを追加しながら元のエラーを保持します。%vはテキストのみを保持しています。

  • errors.Isはエラーがsentinelと一致するかをチェック、wrapチェーン全体を通じて。エラータイプはIsメソッドを定義して、等価性を自己申告できます。

  • errors.As(またはGo 1.26以降のerrors.AsType)はデータと共に特定の型のエラーを取得します。対象変数の型は返された型と正確に一致する必要があります。

  • errors.Joinと複数の%wにより、1つのエラーが複数の子エラーをwrapできます。

最後に、wrapをAPI設計の一部と見なしてください。wrapは意図的に行い、パッケージの境界でインフラストラクチャエラーをドメインエラーに翻訳し、メッセージ文字列ではなくエラーのアイデンティティに基づいてテストを作成してください。

golangエラーハンドリングerrorsベストプラクティス