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を返します:
==による比較でtargetと同じ、または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がすでにチェーンを走査しているため、methodIs内でUnwrapを呼び出さないでください。MethodIsは現在のエラーレイヤーのみに答えるべきです。
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は意図的に行い、パッケージの境界でインフラストラクチャエラーをドメインエラーに翻訳し、メッセージ文字列ではなくエラーのアイデンティティに基づいてテストを作成してください。