メインコンテンツまでスキップ

Goのエラーハンドリング

.NET (C#) の経験がある開発者向けに、error 型、エラーのラップ、errors.Is / errors.As、panic / recover の使いどころを解説します。

1. 例外を使わない設計の考え方​

Go には try / catch / throw がありません。失敗しうる関数は、最後の戻り値として error を返し、呼び出し側が明示的に確認するのが基本です。

f, err := os.Open("config.json")
if err != nil {
return fmt.Errorf("open config: %w", err)
}
defer f.Close()

この設計には次の意図があります。

  • 制御フローが見える: どの呼び出しが失敗しうるかがコード上で分かる (C# では例外がどこから飛ぶか分かりにくい)
  • エラーは値: 通常の値と同じように、変数に入れる・比較する・加工する・集約することができる
  • 例外はプログラムのバグ用: panic は回復不能な状況に限定する

error インターフェース​

error は標準で組み込まれたインターフェースで、実体は次のとおりです。

type error interface {
Error() string
}

Error() string メソッドを持つ型はすべて error として扱えます。Exception のような巨大な基底クラスは存在せず、スタックトレースも標準では付きません。

2. .NET との対応表​

.NET (C#)Go
try / catch / throwif err != nil { return err }
Exception 型階層error インターフェース / sentinel error / カスタム型
InnerExceptionfmt.Errorf("%w") / errors.Unwrap
catch (X ex) whenerrors.Is / errors.As
finally / usingdefer
AggregateExceptionerrors.Join
throw new ArgumentNullException値を検証して error を返す (プログラマのバグは panic)
Exception.StackTrace標準では付かない (必要なら slog や外部ライブラリで補う)
Result<T> パターン (OneOf 等)(T, error) の多値戻り値が標準形
Environment.FailFast / 未処理例外panic / log.Fatal

3. エラーの生成と返却​

エラーの作り方​

import (
"errors"
"fmt"
)

err1 := errors.New("something went wrong") // 固定メッセージ
err2 := fmt.Errorf("user %d not found", 42) // 書式付き
err3 := fmt.Errorf("load user: %w", err2) // 元のエラーをラップ

標準的な書き方​

func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
return &cfg, nil
}

ポイント:

  • エラーがあるときは他の戻り値をゼロ値 (または nil) にして返す。呼び出し側は err を先に確認する
  • if err := ...; err != nil { } の形式でスコープを狭める
  • エラーを握りつぶさない。無視する場合は _ = f.Close() のように意図を明示する

4. エラーのラップと判定​

%w によるラップ (InnerException 相当)​

fmt.Errorf の %w で元のエラーを包み、コンテキストを付け足しながら呼び出し元へ返すことができます。%v や %s では元のエラーの情報が文字列になってしまい、後から判定できなくなります。

return fmt.Errorf("get user %d: %w", id, err) // 判定できる
return fmt.Errorf("get user %d: %v", id, err) // 判定できない (チェーンが切れる)

sentinel error と errors.Is​

特定の失敗を表すあらかじめ定義されたエラー値 (sentinel error) と比較します。ラップされていても、チェーンをたどって判定されます。

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

func FindUser(id int) (*User, error) {
// ...
return nil, fmt.Errorf("find user %d: %w", id, ErrNotFound)
}

user, err := FindUser(1)
if errors.Is(err, ErrNotFound) {
// 404 として扱うなど
}

err == ErrNotFound ではなく、必ず errors.Is を使います。ラップされていると == では一致しません。

標準ライブラリの例: io.EOF, sql.ErrNoRows, os.ErrNotExist, context.Canceled, context.DeadlineExceeded

カスタムエラー型と errors.As​

追加情報 (フィールド) を持つエラーは、Error() メソッドを実装した型として定義します。errors.As で型として取り出します (catch (X ex) 相当)。

type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s: %s", e.Field, e.Message)
}

func Validate(u User) error {
if u.Name == "" {
return &ValidationError{Field: "name", Message: "required"}
}
return nil
}

err := fmt.Errorf("create user: %w", Validate(u))

var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println(ve.Field, ve.Message) // name required
}
  • errors.As の第2引数はターゲット型へのポインタです (*ValidationError の変数のアドレス &ve)。
  • チェーン内に一致する型が見つかれば、ve に代入されて true を返します。

使い分け​

目的手段
「この失敗かどうか」だけ知りたいsentinel error + errors.Is
失敗の詳細 (フィールド・コード等) が必要カスタム型 + errors.As
呼び出し元にコンテキストを伝えたいfmt.Errorf("...: %w", err)
呼び出し元に詳細を見せたくない%v で包む (チェーンを切る)

公開 API で %w を使うと、その内側のエラーがAPI の一部になります。内部実装の詳細 (例: 特定の DB ドライバーのエラー) を漏らしたくない場合は、ここで独自のエラーに変換します。

errors.Join (AggregateException 相当)​

複数のエラーをまとめます (Go 1.20 以降)。errors.Is / errors.As もまとめたエラーの各要素に対して機能します。

var errs []error
for _, item := range items {
if err := process(item); err != nil {
errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err))
}
}
return errors.Join(errs...) // errs が空なら nil

5. defer によるクリーンアップ​

defer は関数終了時に実行される処理を登録します。finally や using に相当します。

func Process(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // 関数を抜けるときに必ず実行される

// ...
return nil
}
  • 複数の defer は後入れ先出し (LIFO) で実行されます。
  • defer の引数は defer 文を評価した時点で確定します。
  • ループ内で defer を使うと、関数が終わるまで溜まり続けます。ループ本体を別関数に切り出します。
  • 書き込み系の Close() はエラーを返すことがあります。無視してよいか確認し、必要なら名前付き戻り値でエラーを反映します。
func WriteFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()

_, err = f.Write(data)
return err
}

6. panic / recover を使ってよい場面​

panic は例外のように見えますが、通常のエラー処理には使いません。

panic が適切な場面​

  • プログラマのバグ (到達不能なコード、不変条件の破壊) を示すとき
  • 起動時の初期化に失敗し、続行できないとき (例: 必須の正規表現の regexp.MustCompile、テンプレートの template.Must)
  • ランタイムによる panic (nil ポインタ参照、スライスの範囲外アクセス、nil マップへの書き込みなど) は、基本的にバグを意味する

recover が適切な場面​

recover は defer 内でのみ機能し、panic を捕捉します。使うのは主に次の境界です。

  • HTTP サーバー: 1 つのリクエストの panic でプロセス全体を落とさない (net/http は標準で recover して接続を閉じる)
  • goroutine の最上位: goroutine 内の panic はプロセス全体を落とすため、ワーカーの入口で捕捉してエラーとして記録する
  • ライブラリ内部: 内部の再帰処理などで panic を使い、公開 API の境界で error に変換する
func SafeHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"panic", rec,
"stack", string(debug.Stack()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

使ってはいけない場面​

  • 「ファイルが見つからない」「入力が不正」「ネットワークエラー」など、想定内の失敗を panic で表現する
  • recover で panic を握りつぶして処理を続行する (状態が壊れている可能性がある)
  • 制御フロー (C# で例外をフロー制御に使うのと同様にアンチパターン)

7. エラーメッセージとログの書き方​

メッセージの規約​

  • 小文字で始め、末尾に句読点を付けない (他のメッセージの途中に連結されるため)
  • 「何をしようとして失敗したか」を操作の名前で書く。failed to や error: といった冗長な接頭辞は不要
  • 呼び出しの各層で自分の層のコンテキストだけを足す
// 悪い例
return fmt.Errorf("Failed to get user: %w", err)

// 良い例
return fmt.Errorf("get user %d: %w", id, err)

各層でラップすると、最終的に handle request: get user 42: query users: connection refused のように人間が読める呼び出し経路になります。スタックトレースの代わりです。

ログは 1 回だけ​

エラーは「処理するか、返すか」のどちらかを選びます。両方 (ログに出してから返す) は行いません。同じエラーが各層で重複してログに出るためです。

// 悪い例: 各層でログと return を両方行う
if err != nil {
slog.Error("query failed", "err", err)
return err
}

// 良い例: 途中の層は文脈を足して返すだけ。最上位 (ハンドラー / main) で 1 回だけログに出す
if err != nil {
return fmt.Errorf("query users: %w", err)
}

HTTP ステータスへの変換​

エラーの判定を最上位の 1 か所に集約します。

func writeError(w http.ResponseWriter, err error) {
var ve *ValidationError
switch {
case errors.As(err, &ve):
http.Error(w, ve.Error(), http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "timeout", http.StatusGatewayTimeout)
default:
slog.Error("unhandled error", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}

内部エラーの詳細 (SQL、パスなど) はクライアントに返さず、ログにのみ出力します。

8. C# 開発者がつまずきやすいポイント​

  1. if err != nil の繰り返しが多い: 冗長ですが、Go では意図された設計です。ヘルパー関数で隠蔽せず、各所で適切にコンテキストを付けて返します。

  2. エラーを無視できてしまう: コンパイラは戻り値の error を無視しても警告しません。errcheck (golangci-lint に含まれる) で検出します。

  3. == で比較してしまう: ラップされたエラーは == で一致しません。errors.Is / errors.As を使います。

  4. %w と %v の違い: %v ではチェーンが切れ、errors.Is が機能しなくなります。

  5. nil な具象型を error に入れてしまう: 次のコードは err != nil が true になります。

    func doSomething() error {
    var e *MyError = nil
    return e // error インターフェースに (型=*MyError, 値=nil) が入る。インターフェースとしては nil ではない
    }

    エラーなしの場合は必ず return nil を直接返します。*MyError 型の変数を error として返さないでください。

  6. 例外のようにスタックトレースが付かない: 呼び出し経路は %w で付けるコンテキストメッセージで追跡します。panic 時のみスタックトレースが出力されます。

  7. errors.As のターゲットは非 nil なポインタ: errors.As(err, ve) のように、変数そのもの (ポインタ型の値) を渡すと panic します。&ve のように変数のアドレスを渡します (go vet も検出します)。

  8. panic を例外のつもりで使わない: 外部入力や I/O の失敗で panic すると、サーバーが落ちます。

  9. defer 内のエラー: defer f.Close() はエラーを捨てます。書き込み系のリソースでは結果を確認します。

  10. カスタムエラー型はポインタレシーバーが一般的: errors.As のターゲットも *ValidationError に揃えます。値レシーバーとポインタの混在は一致しない原因になります。

9. コード例​

C#​

public class UserNotFoundException(int id) : Exception($"user {id} not found");

public User GetUser(int id)
{
try
{
return _repo.Find(id) ?? throw new UserNotFoundException(id);
}
catch (SqlException ex) when (ex.Number == 1205)
{
throw new RetryableException("deadlock", ex);
}
}

// 呼び出し側
try
{
var user = service.GetUser(1);
}
catch (UserNotFoundException)
{
return NotFound();
}

Go​

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

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
u, err := s.repo.Find(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user %d: %w", id, ErrUserNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return u, nil
}

// 呼び出し側
user, err := service.GetUser(ctx, 1)
if errors.Is(err, ErrUserNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("get user failed", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
_ = user

10. まとめ​

  • Go のエラーは値であり、(T, error) で返して if err != nil で明示的に処理する。例外のような暗黙の制御フローはない。
  • fmt.Errorf("...: %w", err) で文脈を付けてラップし、errors.Is (sentinel の判定) と errors.As (カスタム型の取り出し) で判定する。== は使わない。
  • defer で後始末を行う。errors.Join で複数のエラーをまとめられる。
  • panic / recover はバグ・初期化失敗・プロセスの境界 (HTTP ハンドラー、goroutine の入口) に限定する。
  • エラーはログに出すか返すかのどちらか。最上位の 1 か所でログ出力し、クライアントへ内部詳細を漏らさない。
  • errcheck などの静的解析で、握りつぶしを機械的に検出する (詳細はツールとLint)。

参考資料​