Go言語(Golang)を触れ始めた開発者が最初に驚き、そして時に戸惑うのが、コードの至る所に現れるif err != nilという記述です。

他の言語にあるような例外処理(try-catch)が存在しないGoでは、エラーを値として愚直に扱うことが設計思想の根幹にあります。

しかし、この愚直な記述も、書き方次第で可読性の高い洗練されたコードに昇華させることができます。

本記事では、Goのエラーハンドリングをスマートに記述するための基本パターンから、実践的な構成方法までを詳しく解説します。

Goにおけるエラーハンドリングの基本原則

Go言語において、エラーは「例外」ではなく「期待される戻り値の一つ」として定義されています。

標準ライブラリの多くが、通常の戻り値と並んでerror型を返すのはそのためです。

この設計には、エラーの発生箇所を明示的にし、開発者がその処理を無視できないようにするという明確な意図があります。

まずは、最も標準的なエラーチェックの形を確認しましょう。

Go
package main

import (
	"errors"
	"fmt"
)

func doSomething() error {
	// 何らかの処理
	return errors.New("何らかのエラーが発生しました")
}

func main() {
	err := doSomething()
	if err != nil {
		// エラーが発生した場合の処理
		fmt.Printf("エラーを検知: %v\n", err)
		return
	}
	fmt.Println("正常に終了しました")
}
実行結果
エラーを検知: 何らかのエラーが発生しました

このように、関数呼び出しの直後にif err != nilを配置するのが基本です。

しかし、これが連続するとコードが冗長に見えてしまうことがあります。

そこで重要になるのが、「早期リターン」の考え方です。

スマートな記述のための基本パターン:早期リターン

コードをスマートに見せるための第一歩は、正常系の処理をインデントの深い位置に置かないことです。

これを「ハッピーパス(Happy Path)」を左端に寄せる、と表現することもあります。

ガード節によるネストの解消

エラーが発生した際に即座に関数を終了させる「ガード節」を用いることで、深いネストを回避できます。

Go
// 良くない例:ネストが深い
func processData(data string) error {
	err := validate(data)
	if err == nil {
		err = save(data)
		if err == nil {
			fmt.Println("保存完了")
			return nil
		} else {
			return err
		}
	} else {
		return err
	}
}

// スマートな例:早期リターン(ガード節)の活用
func processDataSmart(data string) error {
	if err := validate(data); err != nil {
		return fmt.Errorf("検証エラー: %w", err)
	}

	if err := save(data); err != nil {
		return fmt.Errorf("保存エラー: %w", err)
	}

	fmt.Println("保存完了")
	return nil
}

上記のスマートな例では、if err := function(); err != nil という形式で、変数のスコープをif文の中に限定しています。

これにより、変数の生存期間が明確になり、コード全体がスッキリとした印象になります。

エラーに文脈を与える:エラーラッピング

単にreturn errとするだけでは、大規模なアプリケーションにおいて「どこで、なぜエラーが発生したのか」を追跡するのが困難になります。

Go 1.13以降、標準ライブラリでエラーのラッピングがサポートされました。

fmt.Errorf と %w の活用

エラーを上位の関数へ返す際は、fmt.Errorfと特殊な書式指定子%wを使用して、エラーにコンテキストを追加するのがスマートな作法です。

Go
func fetchData(id int) ([]byte, error) {
	data, err := database.Query(id)
	if err != nil {
		// エラーが発生した場所や状況を付与して返す
		return nil, fmt.Errorf("ID:%d のデータ取得に失敗しました: %w", id, err)
	}
	return data, nil
}

ここで%w(wrap)を使うことで、元のエラー情報を保持したまま、新しいメッセージを付加できます。

これにより、呼び出し元で元のエラーが何であったかを判定(errors.Is や errors.As)することが可能になります。

エラー判定のベストプラクティス:errors.Is と errors.As

Goでは、特定のエラーが発生したかどうかを判定するために、直接的な比較(err == ErrNotFound)ではなく、専用の関数を使うことが推奨されます。

特定のエラー値を判定する errors.Is

Sentinel Error(特定の値として定義されたエラー)を判定する場合は、errors.Isを使用します。

これはラッピングされたエラーに対しても再帰的にチェックを行います。

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

func main() {
	err := fmt.Errorf("処理中にエラー: %w", ErrNotFound)

	// ラッピングされていても判定可能
	if errors.Is(err, ErrNotFound) {
		fmt.Println("データが見つかりませんでした")
	}
}

特定の型を抽出する errors.As

エラーが特定のカスタム構造体型であるかを判定し、そのフィールドにアクセスしたい場合はerrors.Asを使用します。

Go
type MyError struct {
	Code    int
	Message string
}

func (e *MyError) Error() string {
	return e.Message
}

func main() {
	var err error = &MyError{Code: 404, Message: "カスタムエラー"}

	var myErr *MyError
	if errors.As(err, &myErr) {
		fmt.Printf("エラーコード: %d, メッセージ: %s\n", myErr.Code, myErr.Message)
	}
}
実行結果
エラーコード: 404, メッセージ: カスタムエラー

冗長な記述を減らす実践的なテクニック

if err != nil の繰り返しを減らすために、いくつかのパターンが考案されています。

ただし、これらは明示性を損なわない範囲で使用することが重要です。

1. エラーを蓄積する構造体(Error Writerパターン)

例えば、複数の書き込み処理を行う場合、毎回エラーチェックを行うとコードが長くなります。

これを構造体に閉じ込めることで簡略化できます。

Go
type errWriter struct {
	w   io.Writer
	err error
}

func (ew *errWriter) write(p []byte) {
	if ew.err != nil {
		return
	}
	_, ew.err = ew.w.Write(p)
}

func (ew *errWriter) Error() error {
	return ew.err
}

// 呼び出し側
ew := &errWriter{w: os.Stdout}
ew.write([]byte("Goの"))
ew.write([]byte("エラーハンドリングは"))
ew.write([]byte("スマートに書ける\n"))

if err := ew.Error(); err != nil {
	log.Fatal(err)
}

このパターンは、標準ライブラリのbufio.Scannerなどでも採用されています。

ループや連続した操作において、最後にまとめてエラーを確認する手法は非常に強力です。

2. ヘルパー関数への切り出し

特定の共通処理に付随するエラーチェックは、小さな関数にカプセル化することで、メインのロジックを清潔に保てます。

Go
func mustOpen(filename string) *os.File {
	f, err := os.Open(filename)
	if err != nil {
		// 回復不能な場合にのみ panic を使用する
		panic(fmt.Sprintf("ファイルのオープンに失敗しました: %v", err))
	}
	return f
}

(注:panic は初期化処理など、プログラムの継続が不可能な場合に限定して使用してください。通常の業務ロジックでは推奨されません。)

高度な構成:カスタムエラー型とドメインエラー

中規模以上のアプリケーションでは、エラーを層(Layer)ごとに整理することが求められます。

例えば、インフラ層(DB操作など)のエラーを、そのままビジネスロジック層で扱うのは適切ではありません。

内部エラーをドメインエラーへ変換する

データベースの固有エラー(SQLドライバーの独自エラーなど)を、ビジネスロジックが理解できる「ユーザーが見つからない」という汎用的なエラーに変換します。

レイヤー役割エラー処理の例
インフラ層DBアクセス、外部API呼び出しsql.ErrNoRows などをそのまま受け取る
ドメイン層ビジネスロジックの実行インフラ層のエラーを ErrUserNotFound などに変換
プレゼンテーション層レスポンスの返却ErrUserNotFound を見て HTTP 404 を返す

このように、エラーの抽象度を適切に管理することで、システム全体の堅牢性が向上します。

現代的なエラー処理:ログとテレメトリの統合

2020年代後半のGo開発においては、単にエラーを返すだけでなく、オブザーバビリティ(可観測性)を意識した実装が標準となっています。

エラーハンドリングを行う箇所で、単にログを出力するのではなく、コンテキスト(context.Context)を引き回し、トレースIDと共にエラーを記録することが重要です。

Go
func processOrder(ctx context.Context, orderID string) error {
	if err := db.Save(ctx, orderID); err != nil {
		// ログにコンテキスト情報を含める(疑似コード)
		logger.Error(ctx, "注文の保存に失敗しました", "order_id", orderID, "error", err)
		return fmt.Errorf("order save failed: %w", err)
	}
	return nil
}

まとめ

Go言語におけるif err != nilは、冗長なボイラープレートではなく、安全なソフトウェアを構築するための対話です。

スマートな記述を実現するためには、以下のポイントを意識しましょう。

  1. 早期リターンを活用して、コードの主軸(ハッピーパス)を左側に保つ。
  2. fmt.Errorf("%w", err) を使い、エラーに意味のあるコンテキストを付与する。
  3. エラーの判定には errors.Iserrors.As を使用する。
  4. 連続するエラーチェックには、Error Writerパターンのようなカプセル化を検討する。

一見すると手間のかかるGoのエラーハンドリングですが、これらのパターンを身につけることで、変更に強く、デバッグのしやすい、プロフェッショナルなコードを書くことができるようになります。

エラーと正面から向き合い、それを適切にコントロールすることこそが、Goエンジニアとしてのスキルの見せ所です。