Go言語でコードを書いていて、エラーハンドリングの正しいやり方がよくわからない。書籍やチュートリアルでは基本的なif err != nilしか載っていないけれど、実務ではもっと複雑な状況が多い。エラーをどう構造化すればいいのか、どうロギングすればいいのか、チーム開発ではどう統一すればいいのか――こうした悩みを抱えるエンジニアは少なくありません。
Go言語はシンプルさを重視する言語ですが、エラーハンドリングこそが本当の実力を問う部分です。このガイドでは、Go言語のエラーハンドリングにおけるベストプラクティスから、実務で役立つパターンまで、すぐに使える知識をまとめました。
Go言語のエラーハンドリングが難しい理由
Go言語のエラーハンドリングは、他言語とは異なるアプローチを取っています。JavaやPythonは例外(Exception)の仕組みを持ちますが、Goは明示的なエラー値の返却を採用しています。この設計思想は強力ですが、使い手によって品質が大きく左右される危険性があります。
多くのエンジニアが陥るのは、エラーを単に判定して終わりにしてしまうパターンです。エラーが発生した時の状態、なぜそのエラーが起きたのか、どこまで巻き戻すべきなのかを考えずに書いてしまうと、本番環境でデバッグが極めて困難になります。
Go言語のエラーインターフェースを正しく理解する
Go言語において、エラーは単なるインターフェースです。errorインターフェースはわずか1つのメソッドを持ちます。
type error interface {
Error() string
}
このシンプルさが、Go言語のエラーハンドリングの核です。どの値でもError()メソッドを実装すれば、エラーとして扱えます。標準ライブラリのerrors.New()やfmt.Errorf()を使えば簡単にエラーを作成できます。
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
この基本をおさえた上で、より実務的なパターンに進む必要があります。
エラーラッピングとスタックトレース
Go 1.13以降、fmt.Errorf()に%w動詞が追加されました。これはエラーをラッピングする際に元のエラー情報を保持する仕組みです。
func readFile(path string) ([]byte, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
return data, nil
}
ラッピングされたエラーは、errors.Is()とerrors.As()で検査できます。これにより、呼び出し元は元のエラー型を特定し、適切に処理することができます。
data, err := readFile("config.json")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// ファイルが見つからない場合の処理
log.Println("Config file not found")
} else {
// その他のエラーの処理
return err
}
}
カスタムエラー型の実装
実務では、汎用的なエラーメッセージでは不十分な場合が多くあります。HTTP ステータスコード、エラーコード、内部的な詳細情報など、複数の情報を保持したいなら、カスタムエラー型を定義すべきです。
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error in field %s: %s", e.Field, e.Message)
}
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "invalid email format",
}
}
return nil
}
カスタムエラー型を使うと、型アサーション(errors.As())で特定のエラー情報にアクセスでき、より細かい制御が可能になります。
err := validateEmail("invalid-email")
if err != nil {
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Error in field: %s\n", valErr.Field)
}
}
エラーハンドリングの実務パターン集
パターン1:呼び出し元への委譲
最もシンプルで多用されるパターンです。エラーを処理せず、そのまま呼び出し元に返します。このとき、エラーコンテキストを付与することが重要です。
func fetchUserData(userID string) (User, error) {
resp, err := http.Get("https://api.example.com/users/" + userID)
if err != nil {
return User{}, fmt.Errorf("failed to fetch user data: %w", err)
}
// 処理続行...
}
パターン2:エラーのログと変換
特定のエラーに対して、内部的なログを記録した上で、呼び出し元には別のエラーを返すパターンです。データベース接続エラーなど、内部情報を隠すべき場合に有効です。
func queryDatabase(query string) ([]Record, error) {
rows, err := db.Query(query)
if err != nil {
log.Errorf("database query failed: %v", err)
return nil, errors.New("database operation failed")
}
// 処理続行...
}
パターン3:リトライロジック
一時的なエラー(ネットワーク障害など)に対しては、リトライが有効です。
func fetchWithRetry(url string, maxRetries int) ([]byte, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
resp, err := http.Get(url)
if err == nil {
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
lastErr = err
time.Sleep(time.Duration(math.Pow(2, float64(i))) * time.Second)
}
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}
パターン4:エラー集約
複数の処理が並行して実行される場合、各ゴルーチンのエラーを集約する必要があります。
func processMultiple(items []string) error {
errChan := make(chan error, len(items))
for _, item := range items {
go func(i string) {
if err := process(i); err != nil {
errChan <- fmt.Errorf("processing %s failed: %w", i, err)
} else {
errChan <- nil
}
}(item)
}
var errs []error
for i := 0; i < len(items); i++ {
if err := <-errChan; err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("multiple errors: %v", errs)
}
return nil
}
AIを活用したエラーハンドリング設計
ChatGPTやClaudeなどの生成AIは、エラーハンドリング戦略の設計に役立ちます。特に、既存のコードを分析してエラーパターンを抽出する際に有効です。
例えば、「このGo関数のエラーハンドリングをレビューしてください」とAIに投げれば、見落としているエラーケースや改善点を指摘してくれます。また、Cursor Rulesを活用することで、チーム全体で統一されたエラーハンドリングスタイルを強制できます。
Claude codeを使った自動化の極意では、このような設計作業を自動化する技法も紹介されており、大規模なコードベースのリファクタリングを効率化できます。
エラーロギングの戦略
エラーハンドリングと同様に重要なのが、エラーロギングです。単にlog.Println()するだけでは不十分です。構造化ロギング(Structured Logging)を導入することで、本番環境でのトラブルシューティングが格段に効率化されます。
import "go.uber.org/zap"
func processOrder(orderID string) error {
logger, _ := zap.NewProduction()
defer logger.Sync()
order, err := fetchOrder(orderID)
if err != nil {
logger.Error("failed to fetch order",
zap.String("orderID", orderID),
zap.Error(err),
)
return err
}
if err := validateOrder(order); err != nil {
logger.Warn("order validation failed",
zap.String("orderID", orderID),
zap.String("reason", err.Error()),
)
return err
}
return nil
}
構造化ログを使うと、JSON形式で出力でき、ログ集約ツール(DatadogやStackdriverなど)で簡単に検索・分析できます。
エラーハンドリングの比較:Go vs 他言語
| 項目 | Go | Python | Java |
|---|---|---|---|
| エラーの仕組み | 戻り値 | 例外 | 例外 |
| 実装の明示性 | 高い(必ずif err != nilを書く) |
低い(try-except を忘れやすい) | 中程度(チェック例外・非チェック例外がある) |
| パフォーマンス | 優秀(エラーオブジェクト作成のみ) | 低い(スタックアンワインド処理) | 中程度 |
| スタックトレース自動取得 | 自動取得なし(手動で実装が必要) | 自動取得あり | 自動取得あり |
Goのアプローチは最初は手間に見えますが、明示的であるがゆえに、エラーハンドリング漏れを防げます。また、パフォーマンスも例外ベースの言語より優秀です。
よくある間違いと対策
間違い1:エラーを無視する
最悪のパターンは、_ = someFunction()としてエラーを無視することです。これは本来はできるだけ避けるべき行為ですが、やむを得ない場合は明示的なコメントをつけましょう。
// エラーは無視可能(この関数は副作用のみが目的)
_ = logger.Sync()
間違い2:エラーを無限ラッピング
エラーハンドリングの層が深くなると、fmt.Errorf()を重ねてしまいがちです。これはエラーメッセージが肥大化し、むしろ可読性を損なわせます。
// 悪い例
return fmt.Errorf("layer1: %w", fmt.Errorf("layer2: %w", fmt.Errorf("layer3: %w", originalErr)))
ラッピングは1階層で十分です。複数の情報が必要なら、カスタムエラー型を使いましょう。
間違い3:型アサーション忘れ
カスタムエラーの詳細情報にアクセスする際、errors.As()を使わず直接キャストしてしまうと、nil参照エラーで異常終了する危険があります。
// 危険な例
err := someFunc()
valErr := err.(*ValidationError) // nilの場合、パニック
fmt.Println(valErr.Field)
// 安全な例
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println(valErr.Field)
}
チーム開発でのエラーハンドリング統一
複数の開発者がいるプロジェクトでは、エラーハンドリングのスタイルが統一されていないと、コードレビューや保守が困難になります。
以下の施策を推奨します。
- エラーハンドリング標準ガイドをドキュメント化する
- linter(
golangci-lintなど)でエラー無視をチェックする - コードレビューテンプレートにエラーハンドリング確認項目を含める
- 定期的なコード品質監査を実施する
Cursor Rules 書き方・チームで共有する方法で紹介されているように、AI開発ツールを使ってガイドラインを自動化することも有効です。
おすすめ書籍・ガジェット
- プログラミング言語Go 完全入門 ― Go言語の基礎から実務パターンまで網羅した必読書。エラーハンドリングの章も充実しており、初心者から中級者まで幅広く対応しています。
- 実践Go言語 本番環境での開発 ― 実務的なエラーハンドリング、ロギング、テストパターンが詳しく解説されています。大規模プロジェクト開発に携わるエンジニア向けです。
- HHKB Professional Hybrid ― エンジニアの生産性向上に欠かせない高級キーボード。長時間のコーディングでも疲れにくく、複雑なエラーハンドリングロジックの実装に集中できます。
まとめ
Go言語のエラーハンドリングは、シンプルながら奥が深い領域です。基本のif err != nilから始まり、エラーラッピング、カスタム型、ロギングと進むにつれて、本当に堅牢なシステムが構築できるようになります。
実務では、常に「このエラーが発生した時、ユーザーにはどう伝えるのか」「デバッグする際に必要な情報はあるのか」を意識してコードを書くことが重要です。AIツールを活用しながら、チーム全体で標準化されたエラーハンドリング文化を作り上げることで、本番環境でのトラブルを最小限に抑えられます。
30代未経験からAIエンジニアへの転職を目指す方も、このようなベストプラクティスの習得が評価対象になります。エラーハンドリングの質は、プロフェッショナルなエンジニアの証です。
FAQ
Go言語でfmt.Errorf()とerrors.New()の使い分けは?
errors.New()は静的なメッセージを持つ単純なエラーを作成します。一方、fmt.Errorf()は変数やコンテキスト情報を含む動的なメッセージを作成でき、Go 1.13以降は%w動詞でエラーラッピングも可能です。実務では、ほぼすべての場合でfmt.Errorf()の使用が推奨されます。元のエラー情報を保持したい場合は必ず%wを使い、単なる情報文字列の場合は%vや%sを使います。
カスタムエラー型を定義するときの命名規則は?
Go言語の慣例では、カスタムエラー型はErrorで終わる名前をつけます。例えばValidationError、NotFoundError、TimeoutErrorなどです。さらに、エクスポート(大文字開始)する必要があります。パッケージ内で限定的に使うなら小文字でもよいですが、他パッケージからerrors.As()で検査される場合はエクスポート必須です。
errors.Is()とerrors.As()の違いは?
errors.Is()はエラーが特定の値と同じかどうかを判定します。一方、errors.As()はエラーが特定の型かどうかを判定し、型アサーション後に変数に格納します。Is()はos.ErrNotExistのような事前定義エラーの判定に使い、As()はカスタムエラー型の詳細情報にアクセスする際に使う、と使い分けます。
複数のエラーを同時に処理する場合、どのようにすればいい?
Go 1.20で導入されたerrors.Join()を使うと複数のエラーを1つにまとめられます。並行処理の場合は、チャネルでエラーを集約し、それらをerrors.Join()でまとめるのが標準的です。あるいは、スライスにエラーを追加し、最終的にlen(errs) > 0で判定する方法もあります。重要なのは、複数エラーの存在を明示的に扱うことです。
Goでエラーハンドリングを自動化できるツールはあるか?
golangci-lintのerrcheckルールで、エラーの無視をチェックできます。さらに高度な分析には、Claude codeなどのAIツールを使って、既存コードのエラーハンドリングパターンを自動分析・改善することも可能です。ただし、完全自動化は難しく、エンジニアの判断が常に必要です。