Go

Go言語のエラーハンドリング実装ガイド|errors.Is/As/AsTypeとpanic recoverの使い分け

Go言語のエラーハンドリングは、例外を投げる代わりに error 型の値を戻り値で返すという一点に集約されます。仕組みが単純なぶん、実務で判断に迷うのは文法ではなく運用のほうです。どこで文脈を足すのか、同一性で判定するのか型で判定するのか、panic をどこまで許すのか。本記事では Go 1.26.5(darwin/amd64)で実際にコンパイル・実行した結果をもとに、fmt.Errorf%w によるラップから errors.Is / errors.As、Go 1.26 で追加された errors.AsType、panic と recover の境界、defer での Close 失敗の扱いまでを、判断基準の形で整理します。掲載しているコードは import 文を省いた断片を含みますが、いずれも import を補って Go 1.26.5 で実行しており、掲載した出力はその実行結果をそのまま転記したものです。

まとめ

Goのエラーハンドリングで押さえるべき判断は、次の6点に収まります。

  • ラップは %w で行う%v で埋め込むと errors.Is がチェーンを辿れず、判定が静かに false になります。
  • 同一性の判定は errors.Is、型の取り出しは errors.As。Go 1.26 以降は errors.AsType を優先します。実測で errors.As が 380.2〜404.3 ns/op・1 allocs/op、errors.AsType が 43.02〜44.65 ns/op・0 allocs/op でした。
  • errors.As の target の誤りはビルドを通り、実行時に panic します。検出できるのは go vet であり、errors.AsType なら型システムがコンパイル時に落とします。
  • panic は自パッケージの内側で完結させ、境界で error に変換します。別の goroutine で起きた panic は呼び出し元では回収できません。
  • defer の Close() の戻り値を捨てない。名前付き戻り値と errors.Join で書き込み結果に合流させます。
  • 標準の判定ヘルパーはラップを辿りませんos.IsNotExist はラップ済みのエラーで false を返すため、errors.Is(err, fs.ErrNotExist) を使います。

以下、それぞれを実行結果つきで見ていきます。

Goのエラーが値であることの帰結とerrorインターフェースの最小実装

errorインターフェースの定義と自作エラー型の書き方

Goの error は言語機能ではなく、メソッドを1つだけ持つ組み込みインターフェースです。定義はこれだけです。

type error interface {
	Error() string
}

したがって自作のエラー型は、Error() string を実装した任意の型になります。判定側で構造化された情報を取り出したいときは、文字列を組み立てるだけでなくフィールドを持たせます。

type ValidationError struct {
	Field string
}

func (e *ValidationError) Error() string {
	return "invalid field: " + e.Field
}

ここでレシーバをポインタにするか値にするかは、あとの errors.As / errors.AsType の書き方に直結します。ポインタレシーバで実装した場合、error を満たすのは *ValidationError であって ValidationError ではありません。この区別を曖昧にしたまま書くと、後述する実行時 panic を踏みます。

早期リターンで正常系を左端に寄せる関数の形

Goのエラー処理が冗長だと言われる原因は if err != nil の頻出にありますが、この形は正常系のコードをインデントの左端に保つための構造です。異常系を先に返し切ることで、関数の主筋が縦一列に並びます。

func loadConfig(path string) (*Config, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("設定の読み込み: %w", err)
	}
	defer f.Close()

	var c Config
	if err := json.NewDecoder(f).Decode(&c); err != nil {
		return nil, fmt.Errorf("decode %s: %w", path, err)
	}
	return &c, nil
}

エラー時に返す第1戻り値は、ゼロ値(ここでは nil)で統一します。エラーがあるのに部分的に構築した値を返すと、呼び出し元がエラーを無視したときに壊れた値がそのまま流れていく、というのが典型的な事故です。error 型そのものの基礎はGoのエラー処理とは?基本的な概念と特徴を解説で整理しています。

fmt.Errorfの%wによるラップとerrors.Joinでの複数エラーの束ね

%wと%vの分岐点はerrors.Isがチェーンを辿れるかどうか

エラーに文脈を足して上位へ返すとき、%w%v のどちらを使うかで意味が変わります。%w はラップしてチェーンを保ち、%v は文字列として埋め込むだけでチェーンを切ります。見た目のメッセージが同じでも判定結果が反転するため、ここは機械的に %w を選んで構いません。

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

func findUser(id string) error {
	if id == "" {
		return fmt.Errorf("findUser(%q): %w", id, ErrNotFound)
	}
	return nil
}

func main() {
	err := findUser("")
	fmt.Println("err       :", err)
	fmt.Println("errors.Is :", errors.Is(err, ErrNotFound))

	// %v で埋め込むとチェーンが切れる
	broken := fmt.Errorf("findUser: %v", ErrNotFound)
	fmt.Println("ラップなし  :", errors.Is(broken, ErrNotFound))
}

実行結果は次のとおりです。

err       : findUser(""): user not found
errors.Is : true
ラップなし  : false

%v のほうはメッセージに “user not found” が含まれているにもかかわらず errors.Is が false になります。エラー文字列を目視すると正しく見えるため、この取り違えはテストでも見落とされがちです。

errors.Joinで複数エラーを1つのerrorにまとめる書き方

Go 1.20 で追加された errors.Join は、複数のエラーを1つの error にまとめます。バリデーションのように「最初の1件で止めず全件返したい」場面と、後述する defer での合流に使います。

joined := errors.Join(ErrNotFound, os.ErrPermission)
fmt.Println("Join Is(NotFound)  :", errors.Is(joined, ErrNotFound))
fmt.Println("Join Is(Permission):", errors.Is(joined, os.ErrPermission))
fmt.Printf("Join の文字列表現:\n%v\n", joined)
Join Is(NotFound)  : true
Join Is(Permission): true
Join の文字列表現:
user not found
permission denied

束ねた後も、それぞれのセンチネルに対して errors.Is は真を返します。文字列表現は連結ではなく改行区切りになるため、1行想定のログ基盤に投げるとレコードが分断されます。構造化ログに載せるなら、%v をそのまま流さずフィールドへ分けて格納してください。同じ Go 1.20 から fmt.Errorf%w を複数個書けるようになっており、こちらも両方の判定が真になります。

var (
	ErrBadRequest  = errors.New("bad request")
	ErrRateLimited = errors.New("rate limited")
)

multi := fmt.Errorf("保存に失敗: %w / %w", ErrBadRequest, ErrRateLimited)
fmt.Println(errors.Is(multi, ErrBadRequest), errors.Is(multi, ErrRateLimited)) // true true

ラップ時にメッセージが二重化する典型パターンと修正

ラップで実際に頻発する不具合が、メッセージの二重化です。標準ライブラリの多くは既に操作名と対象を含んだエラーを返すため、呼び出し側で同じ情報を足すと重複します。os.Open*fs.PathError を返し、その文字列は “open <path>: …” の形をしています。

// 修正前:os.Open が返す *fs.PathError が既に "open <path>" を含む
return fmt.Errorf("open %s: %w", path, err)

// 修正後:呼び出し元の文脈だけを足す
return fmt.Errorf("設定の読み込み: %w", err)

同じ欠損ファイルで両者を実行すると、差は明らかです。

修正前: open /tmp/missing.json: open /tmp/missing.json: no such file or directory
修正後: 設定の読み込み: open /tmp/missing.json: no such file or directory

ラップで足すべきは、下位のエラーが知り得ない情報だけです。ファイルパスは os.Open が既に知っています。一方で「何のために開いていたのか」は上位しか知りません。この線引きで書くと、5層ラップしても読めるメッセージになります。

errors.Is・errors.As・errors.AsTypeの使い分けと実測性能差

同一性判定のerrors.Isと型取り出しのerrors.As

判定関数は用途が明確に分かれます。「このエラーか?」を訊くのが errors.Is、「この型として中身を取り出したい」が errors.As です。前者はセンチネルエラーとの照合、後者はフィールドを読みたいときに使います。

err := fmt.Errorf("save: %w", &ValidationError{Field: "email"})

// errors.As:ポインタのポインタを渡す
var ve *ValidationError
if errors.As(err, &ve) {
	fmt.Println("As    :", ve.Field) // As    : email
}

errors.As の第2引数は「エラー型を指すポインタ」である必要があり、&ve のように1段余分にアドレスを取る形になります。この引数は any 型で受け取られるため、型の整合はコンパイラが検査しません。

errors.Asのtarget誤りが実行時panicになる条件

ポインタを取り忘れて値を渡した場合、ビルドは通ります。落ちるのは実行時です。

var ve ValidationError
fmt.Println(errors.As(err, ve)) // ポインタを取り忘れている
panic: errors: target must be a non-nil pointer

go build は通り、go run でも実行時まで落ちません。検出できるのは go vet です。

main.go:23:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type

この errorsas 検査は go test が既定で走らせる vet のサブセットにも含まれるため、テストを1回でも回せばビルド段階で落ちます。裏を返せば、vet を CI に通していないプロジェクトでだけ本番まで生き残る誤りということです。エラー処理は異常時にしか通らない経路なので、静的検査を挟まないと発見の機会そのものがありません。

Go 1.26追加のerrors.AsTypeによるコンパイル時検査と実測値

Go 1.26 で追加された errors.AsType は、この問題を型引数で解決します。標準ライブラリのソース(GOROOT/src/errors/wrap.go)で定義を確認すると、シグネチャは次のとおりです。

func AsType[E error](err error) (E, bool)

呼び出し側はポインタのポインタを組み立てる必要がなくなり、戻り値で直接受け取ります。

if ve, ok := errors.AsType[*ValidationError](err); ok {
	fmt.Println("AsType:", ve.Field) // AsType: email
}

型引数に error を満たさない型を書くと、実行前に止まります。ポインタレシーバで Error() を実装した型に対して値型を指定した場合、コンパイラは次のように拒否しました。

ValidationError does not satisfy error (method Error has pointer receiver)

先ほど実行時 panic になった取り違えが、ここではビルドで落ちます。違いは検出の主体です。errors.As の安全性は vet を回す運用に依存しますが、errors.AsType は型システムが保証します。Go 1.26 以降のコードで型を取り出すなら errors.AsType を既定にしてよいと考えます。errors.As を残す理由は、Go 1.25 以前もビルド対象に含むライブラリを書いている場合だけです。

性能差も小さくありません。3段ラップしたエラーに対して -benchmem -benchtime=2s -count=5 で計測した結果です(Go 1.26.5 / darwin/amd64 / Intel Core i9-9880H)。

関数 ns/op B/op allocs/op
errors.As 380.2〜404.3 8 1
errors.AsType 43.02〜44.65 0 0

errors.As はリフレクションで target の型を検査するため、1回あたり8バイトのアロケーションが発生します。errors.AsType は型アサーションで済むためアロケーションがゼロでした。エラー経路が秒間数万回通るホットパスでない限り体感差は出ませんが、アロケーションが消えることはGCへの寄与という点で意味があります。バージョンごとの追加機能はGo 1.27の新機能・変更点まとめ|最新バージョンの確認方法と移行ガイドにまとめています。

panicとrecoverを使ってよい境界とgoroutineでの回収漏れ

recoverでpanicをerrorへ変換する境界関数

panic は例外の代替ではありません。Goの標準ライブラリが panic を使うのは、プログラマのバグ(範囲外アクセス、nil 参照、ゼロ除算)を示す場面に限られます。想定内の失敗に panic を使うと、呼び出し元は error を見ても異常に気づけません。

例外的に recover が要るのは、パッケージの境界です。内部で panic しうる処理を外へ漏らさず error に変換します。

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide: %v", r)
		}
	}()
	return a / b, nil
}
fmt.Println(safeDivide(10, 2)) // 5 <nil>
fmt.Println(safeDivide(10, 0)) // 0 safeDivide: runtime error: integer divide by zero

戻り値を名前付き(result int, err error)にしている点が要です。無名の戻り値だと defer 内での代入が呼び出し元に反映されず、panic を握り潰しただけで err が nil のまま返ります。

別goroutineのpanicが呼び出し元でrecoverできない理由

recover は panic した関数と同じ goroutine の defer でしか機能しません。HTTPサーバのハンドラで recover を仕込んでいても、その中から起動した goroutine が panic すればプロセス全体が落ちます。

func main() {
	defer func() {
		fmt.Println("main の recover:", recover())
	}()
	go func() { panic("worker failed") }()
	select {}
}
panic: worker failed

goroutine 7 [running]:
main.main.func2()
	/tmp/main.go:9 +0x25
created by main.main in goroutine 1
	/tmp/main.go:9 +0x37

main 側の recover は一度も呼ばれないままプロセスが終了しました(goroutine 番号は実行ごとに変わります)。回収したいなら、起動する goroutine の内側に defer を置きます。

done := make(chan struct{})

go func() {
	defer close(done)
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("goroutine 側で回収:", r)
		}
	}()
	panic(errors.New("worker failed"))
}()

この形なら “goroutine 側で回収: worker failed” が出力され、main は生存します。goroutine を起動するヘルパーを自前で用意し、その中に recover を必ず入れておく運用が現実的です。goroutineそのものの挙動はゴルーチン(Goroutine)とは?Go言語の並行処理を支える軽量スレッドの仕組みと使い方で解説しています。

deferのClose失敗を握り潰さない名前付き戻り値パターン

defer f.Close() は広く書かれていますが、書き込み系のファイルではバグになります。バッファのフラッシュが Close のタイミングで走るため、ディスク不足やネットワーク断による書き込み失敗が Close の戻り値としてのみ現れるからです。捨てると「成功したのにデータが欠けている」状態になります。

名前付き戻り値と errors.Join を組み合わせると、本処理のエラーと Close のエラーを両方残せます。

func writeFile(path string) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("出力先の作成: %w", err)
	}
	defer func() {
		if cerr := f.Close(); cerr != nil {
			err = errors.Join(err, fmt.Errorf("クローズ: %w", cerr))
		}
	}()

	if _, err := f.WriteString("hello"); err != nil {
		return fmt.Errorf("本文の書き込み: %w", err)
	}
	return nil
}

ラップのメッセージにパスを入れていないのは、前章の線引きに従ったためです。os.Createf.Close() も失敗時には *fs.PathError を返し、その文字列が既にパスを含んでいます。Close が失敗する状況を作るため、意図的に二重 Close させたときの出力が次です。

closed twice: クローズ: close /tmp/out2.txt: file already closed

return nil したあとの defer で err が上書きされ、Close の失敗が呼び出し元まで届いています。読み取り専用で開いたファイルは defer f.Close() のままで構いません。書き込みが伴うリソースだけこの形にする、という線引きが実務的です。

リトライ可否をerrors.Isで判定する再試行制御とcontextでの打ち切り

ネットワーク越しの呼び出しでは、再試行してよいエラーと無意味なエラーを分ける必要があります。判定をエラー型ではなくセンチネルエラーとの照合で書くと、ラップの深さに影響されません。

var (
	ErrRateLimited = errors.New("rate limited")
	ErrBadRequest  = errors.New("bad request")
)

func retryable(err error) bool {
	return errors.Is(err, ErrRateLimited)
}

// 3回目で成功する外部呼び出しの代役
func callAPI(attempt int) error {
	if attempt < 3 {
		return fmt.Errorf("callAPI attempt %d: %w", attempt, ErrRateLimited)
	}
	return nil
}

func withRetry(ctx context.Context, max int) error {
	var last error
	for attempt := 1; attempt <= max; attempt++ {
		last = callAPI(attempt)
		if last == nil {
			return nil
		}
		if !retryable(last) {
			return last
		}
		wait := time.Duration(1<<uint(attempt-1)) * 100 * time.Millisecond
		select {
		case <-ctx.Done():
			return errors.Join(last, ctx.Err())
		case <-time.After(wait):
		}
	}
	return fmt.Errorf("giving up after %d attempts: %w", max, last)
}

設計上の要点は3か所です。待機を time.Sleep ではなく selectctx.Done() で書くこと、打ち切り時に最後のエラーを %w で残すこと、中断時に errors.Join で原因と ctx.Err() の両方を返すことです。3番目を守ると、呼び出し元で次の判定が同時に成立します。

打ち切り: giving up after 2 attempts: callAPI attempt 2: rate limited
  Is(RateLimited): true
中断    : callAPI attempt 1: rate limited
context deadline exceeded
  Is(DeadlineExceeded): true

「レート制限で失敗し続けた末にタイムアウトした」という経緯が、1つのエラー値から復元できます。ctx.Err() だけを返すとレート制限だった事実が消え、監視側からは原因不明のタイムアウトに見えてしまいます。context の扱いはGo言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装で詳しく扱っています。

標準関数の判定ヘルパーがラップを辿らない罠と型付きnilの回避

ラップ済みエラーでos.IsNotExistが偽になる仕組み

os.IsNotExistos.IsPermissionerrors.Is より古く、ラップされたエラーのチェーンを辿りません。ラップを導入した瞬間に、既存の判定が静かに false へ倒れます。

err := fmt.Errorf("設定の読み込み: %w", rawErr) // rawErr は os.Open の戻り値

fmt.Println(os.IsNotExist(err))             // false
fmt.Println(errors.Is(err, fs.ErrNotExist)) // true
fmt.Println(os.IsNotExist(rawErr))          // true(ラップ前なら真)

コンパイルエラーにも panic にもならず、分岐だけが変わります。既存コードにラップを入れる改修をするなら、os.IsNotExistos.IsPermissionerrors.Is(err, fs.ErrNotExist) / errors.Is(err, fs.ErrPermission) へ置き換える作業をセットで行ってください。

型付きnilの返却でerr != nilが常に真になる理由

もう1つの定番が、具体型のポインタを error として返すパターンです。インターフェース値は型と値の組で表現されるため、値が nil でも型が入っていれば == nil は偽になります。

// 誤り:具体型のポインタを返している
func loadBad() error {
	var e *ConfigError // nil のまま
	return e
}

// 正しい:error 型として nil を返す
func loadGood() error {
	var e *ConfigError
	if e != nil {
		return e
	}
	return nil
}
loadBad()  == nil : false
loadGood() == nil : true
loadBad() の中身   : <nil> (型 *main.ConfigError)

出力が <nil> と表示されるのに == nil が偽という、デバッグしづらい状態です。回避策は単純で、関数の戻り値の型を具体型のポインタにせず error にすること、そして成功時は変数を経由せず return nil と書き切ることです。

エラーを処理する層と伝播させる範囲の判断基準

ここは好みが分かれる領域ですが、運用の観点では答えははっきりしています。エラーを発生地点でログに出してはいけません。各層でログを出すと、1つの障害が呼び出し階層の数だけログに現れ、件数を見ても深刻度が分からなくなります。ログは最上位(HTTPハンドラ、ワーカーのループ、main)で1回だけ出します。

中間層の責務は、文脈を %w で足して上へ返すことに限定します。判断の基準は次のとおりです。

  • その層で回復できるなら処理する。キャッシュミス時にオリジンへ取りに行く、といった代替経路がある場合です。
  • 回復できないなら文脈を足して返す。ログもリトライもせず、fmt.Errorf で1行足すだけにします。
  • ユーザーへの提示は最上位でのみ組み立てる。内部のパスやSQLをそのまま返さないよう、境界で errors.Is / errors.AsType により分類してメッセージへ変換します。

この分担にすると、エラーメッセージが「最上位の文脈 → 中間の文脈 → 根本原因」の順に並んだ1行になり、ログ検索でそのまま原因まで辿れます。言語をまたいだエラーハンドリングの考え方はエラーハンドリングとは?例外処理との違いとtry-catch-finallyの使い方で比較しています。

よくある質問

Goに例外処理(try-catch)が無いのはなぜですか?

エラーを制御フローから切り離さず、通常の戻り値として扱う設計を採っているためです。例外機構では、どの関数が何を投げるかがシグネチャに現れず、呼び出し側は捕捉漏れに気づけません。Goは (T, error) という戻り値でそれを型に載せています。代償は if err != nil の記述量で、これは正常系をインデントの左端に保つための対価と考えるのが実際的です。panic と recover は例外の代用ではなく、プログラマのバグを示す仕組みです。

errors.Newとfmt.Errorfはどう使い分けますか?

他の場所から errors.Is で参照される固定のエラー(センチネルエラー)はパッケージ変数として errors.New で定義します。呼び出しごとに文脈が変わるエラーは fmt.Errorf で組み立て、下位のエラーを含める場合は必ず %w でラップします。なお Go 1.26 では、書式指定を含まない fmt.Errorf("x") のアロケーションが errors.New("x") と同程度まで削減されました。

errors.Isとerrors.Asはどちらを使うべきですか?

目的が違うため、どちらか一方ではありません。特定のエラーと一致するかを調べるなら errors.Is、エラーが持つフィールド(フィールド名、HTTPステータス、再試行可否など)を読みたいなら errors.As です。Go 1.26 以降であれば、型を取り出す用途は errors.AsType に置き換えられます。ポインタのポインタを渡す必要がなく、型の誤りがコンパイル時に検出されるためです。

panicはどんなときに使ってよいですか?

プログラムを継続しても意味がない、プログラマ側のバグに限ります。起動時に必須の設定が欠けている、初期化に失敗して以降の処理が成立しない、といった場面です。ユーザー入力の不正、ファイルの不在、ネットワークの失敗はすべて想定内の事象なので error で返します。ライブラリを書く場合は、内部で panic しても公開APIの外へは漏らさず、境界の関数で recover して error に変換してください。

defer内のCloseのエラーは無視してよいですか?

読み取り専用で開いたファイルは defer f.Close() のままで問題ありません。書き込みを伴う場合は無視できません。バッファのフラッシュが Close で実行されるため、書き込みの失敗が Close の戻り値としてだけ現れることがあるからです。名前付き戻り値を使い、defer 内で errors.Join によって本処理のエラーと合流させてください。

関連記事

資料請求

RELATED POSTS 関連記事