Go中級 #2 エラー処理パターン

読了 7分

基礎#4errorの基本を見たなら、今回の記事はその上に実戦的なパターンを載せます — wrapping、検査、カスタム型、そしてpanicが似合う場面まで。

エラーwrapping — %w #

fmt.Errorf%w動詞が中核です。元のエラーを内側に挟み込んだ新しいエラーを作ります。

wrapping 基本
func readConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("config 読み込み失敗 (%s): %w", path, err)
	}
	// ...
	return nil
}

この新しいエラーは:

  • メッセージがより詳しくなる(config 読み込み失敗 (...): open ...: no such file...)
  • 元のエラーを内側に持っているerrors.Is/errors.Asで取り出せる

%v vs %wの違い #

%v vs %w
err1 := fmt.Errorf("失敗: %v", origErr)   // メッセージだけ結合 — 元のエラー情報を失う
err2 := fmt.Errorf("失敗: %w", origErr)   // 元のエラーを保存 + メッセージ

%vは単純な文字列結合。%wは内側に元のエラーを保存。エラーを上に流すときはほぼ常に%wを使います。

errors.Is — sentinelエラー比較 #

事前に定義されたエラーと比較するとき。

errors.Is
import "errors"

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

func lookup(key string) (string, error) {
	if key == "" {
		return "", fmt.Errorf("lookup: %w", ErrNotFound)
	}
	return "value", nil
}

func main() {
	_, err := lookup("")
	if errors.Is(err, ErrNotFound) {
		fmt.Println("見つからない")
	}
}

errors.Isエラーチェーンをたどりながら比較します。wrappingされたエラーの中の元のエラーまで検査します。

err == ErrNotFoundとの違いは — 単純な==はwrapされたエラーの中を見られません。%wで包んだ場合はerrors.Isが正解です。

errors.As — カスタムエラー型を取り出す #

エラーが特定の型のインスタンスかを確認し、合っていればその型として変数に格納します。

カスタムエラー型
type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
errors.As で取り出す
func processForm(form Form) error {
	if form.Email == "" {
		return &ValidationError{Field: "email", Message: "必須です"}
	}
	return nil
}

func main() {
	err := processForm(form)

	var verr *ValidationError
	if errors.As(err, &verr) {
		fmt.Printf("フィールド %s 検証失敗: %s\n", verr.Field, verr.Message)
	}
}

errors.As(err, &verr)が中核:

  • 第1引数 — 検査するエラー
  • 第2引数 — 結果を受け取るポインタへのポインタ

チェーンをたどってその型のエラーを見つければ — その値をverrに格納してtrueを返します。その後はverrのフィールド(Field, Message)にアクセス可能です。

カスタムエラー型 — いつ作るか #

次の場面で似合います。

  1. 追加情報が必要なとき — Field、StatusCode、Retryableのようなメタデータ
  2. 呼び出し側が異なる処理をしなければならないとき — 検証エラーはユーザーに見せ、DBエラーはロギングして一般メッセージ
HTTP エラーパターン
type HTTPError struct {
	StatusCode int
	Message    string
	URL        string
}

func (e *HTTPError) Error() string {
	return fmt.Sprintf("HTTP %d %s (%s)", e.StatusCode, e.Message, e.URL)
}

// 呼び出し側
var httpErr *HTTPError
if errors.As(err, &httpErr) {
	if httpErr.StatusCode == 404 {
		// 別の処理
	}
}

Sentinelエラー — 単純な比較 #

値として定義されたエラー(メタデータなし)。

sentinel エラー
var (
	ErrNotFound      = errors.New("not found")
	ErrPermission    = errors.New("permission denied")
	ErrAlreadyExists = errors.New("already exists")
)

if errors.Is(err, ErrNotFound) {
	// ...
}

標準ライブラリでよく見るパターン — io.EOFsql.ErrNoRowsなど。

Sentinel vs カスタム型 — どちらをいつ #

sentinelカスタム型
追加情報の必要性なしあり
比較方法errors.Iserrors.As
定義の短さフィールド定義が必要
外部公開時の互換性単純インターフェース/型公開

単純な分岐にはsentinel、メタデータが必要ならカスタム型。

エラーをそのまま返すか、wrapするか #

ガイド:

正しいパターン
// エラーを上にそのまま → ひとまず wrap (どこで発生したかの手がかりを追加)
data, err := os.ReadFile(path)
if err != nil {
	return fmt.Errorf("config 読み込み: %w", err)
}

// または — 呼び出し側に明確なメッセージならそのまま
if err != nil {
	return err
}

よく使うガイド — 層の境界では wrap、同じ層の中ではそのまま流してもOK。

過剰な wrap — 回避
// 良くない — あまりに多くの箇所で wrap
return fmt.Errorf("foo: %w", err)
return fmt.Errorf("bar: %w", err)
return fmt.Errorf("baz: %w", err)

// 結果: foo: bar: baz: 本物のエラー

毎回wrapするとメッセージが長くなり意味がぼやけます。意味のある境界(関数の入り口、パッケージ境界)でだけwrapするのがすっきりします。

エラー比較の罠 — == vs errors.Is #

== の限界
var ErrFoo = errors.New("foo")

func wrap() error {
	return fmt.Errorf("wrap: %w", ErrFoo)
}

err := wrap()

err == ErrFoo                // false ← wrap されているので
errors.Is(err, ErrFoo)        // true  ← チェーン検査

基礎でif err != nilだけ使うときは単純比較で十分でしたが、sentinelエラーを検査するときはほぼ常にerrors.Is。これがモダンな標準です。

errors.Join — 複数のエラーをまとめる #

Go 1.20+のツール。複数のエラーが同時に発生したとき。

複数のエラー
import "errors"

func validate(form Form) error {
	var errs []error
	if form.Name == "" {
		errs = append(errs, fmt.Errorf("name 欠落"))
	}
	if form.Email == "" {
		errs = append(errs, fmt.Errorf("email 欠落"))
	}
	if len(form.Password) < 8 {
		errs = append(errs, fmt.Errorf("password 8文字以上"))
	}
	return errors.Join(errs...)
}

err := validate(form)
if err != nil {
	fmt.Println(err)   // すべてのエラーが改行で結合されて出力
}

errors.Joinはnilのエラーを自動で除外し、すべてnilならnilを返します。

errors.Isとも動作します — まとまりの中にErrFooがあればerrors.Is(joined, ErrFoo)がtrue。

Panic — 本当に異常な状況だけに #

panicは他の言語のthrowに似ていますが — Goではほとんど使いません。

panic
func divide(a, b int) int {
	if b == 0 {
		panic("0 で除算")
	}
	return a / b
}

panicが起きると:

  1. 関数が即座に終了
  2. deferたちが実行(LIFO)
  3. 呼び出し側も終了、またその呼び出し側… 結局プログラム全体が終了

Goの慣習 — エラーはほぼ常にerror戻り値。panicは次の場面だけに似合います。

  • プログラムが続行してはならない本当に間違った状態 — メモリ不足、前提違反
  • 自分のパッケージの中だけで — 外部の呼び出し側には公開しない(recoverで捕捉してerrorに変換)

recover — panicから生き残る #

defer関数の中でrecoverを呼べばpanicを捕捉できます。

recover
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic: %v", r)
		}
	}()

	return divide(a, b), nil
}

recover()はpanicが進行中ならその値を返してpanicを止めます。defer関数の中でだけ意味があります。

ライブラリ境界で — 内部panicが外部に漏れないようにする場面に似合います。一般のアプリコードではほとんど出会わないツールです。

errors.Unwrap — 直接ほどく #

たまにwrapされたエラーを一段だけほどいて見たいとき。

Unwrap
err := fmt.Errorf("outer: %w", fmt.Errorf("middle: %w", io.EOF))

inner := errors.Unwrap(err)         // middle: EOF
inner2 := errors.Unwrap(inner)      // EOF
inner3 := errors.Unwrap(inner2)     // nil (これ以上中はない)

直接使う機会は少ないです — errors.Is/Asが内部でやってくれます。デバッグや特殊な場面でだけ。

標準ライブラリでよく出会うエラー #

標準 sentinel エラー
io.EOF                   // 入力の終わり
io.ErrUnexpectedEOF      // 予期せぬ終わり
sql.ErrNoRows            // SQL 結果なし
context.Canceled         // コンテキストキャンセル
context.DeadlineExceeded // タイムアウト
os.ErrNotExist           // ファイルなし
fs.ErrPermission         // 権限なし

これらに出会ったらerrors.Is(err, io.EOF)のように検査。標準ライブラリ関数のエラーは通常こうしたsentinelをwrapして返します。

よく使うパターン #

1) 関数の入り口でコンテキストwrap #

入り口で wrap
func processOrder(orderID string) error {
	if err := validateOrder(orderID); err != nil {
		return fmt.Errorf("processOrder: %w", err)
	}
	if err := saveOrder(orderID); err != nil {
		return fmt.Errorf("processOrder: %w", err)
	}
	return nil
}

関数名をprefixに — 呼び出しスタックを追跡しやすく。

2) 即座に返す (early return) #

early return
data, err := fetch()
if err != nil { return err }

parsed, err := parse(data)
if err != nil { return err }

if err := save(parsed); err != nil { return err }

深いifではなくフラットに。Goコードのほぼ標準的な形です。

3) 一部のエラーは無視 #

意図的な無視
defer file.Close()    // 閉じる失敗は通常無視 (成功しても大した意味なし)

f.WriteString("hi")   // 戻りエラー無視 (些細なケース)

_ = ...で明示的な無視も可能(linter警告回避)。

明示的な無視
_ = file.Close()

まとめ #

今回の記事で整理した内容:

  • %wでエラーwrapping — 元のエラーを保存
  • errors.Isでsentinelエラー比較(チェーン検査)
  • errors.Asでカスタム型を取り出し
  • 追加情報が必要ならカスタム型、単純な分岐はsentinel
  • 関数/パッケージ境界でwrap、毎回の過剰なwrapは回避
  • errors.Joinで複数のエラーをまとめる(Go 1.20+)
  • panicは本当に異常な状況だけ — 通常のエラーはerror戻り値
  • recoverはライブラリ境界程度

次の記事(#3 ゴルーチンとチャネル入門)ではGoの最も強力な武器 — 並行性。軽量なゴルーチンとチャネルを最初から整理します。

X