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 / throw | if err != nil { return err } |
| Exception 型階層 | error インターフェース / sentinel error / カスタム型 |
| InnerException | fmt.Errorf("%w") / errors.Unwrap |
| catch (X ex) when | errors.Is / errors.As |
finally / using | defer |
AggregateException | errors.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# 開発者がつまずきやすいポイント
-
if err != nilの繰り返しが多い: 冗長ですが、Go では意図された設計です。ヘルパー関数で隠蔽せず、各所で適切にコンテキストを付けて返します。 -
エラーを無視できてしまう: コンパイラは戻り値の
errorを無視しても警告しません。errcheck(golangci-lint に含まれる) で検出します。 -
==で比較してしまう: ラップされたエラーは==で一致しません。errors.Is/errors.Asを使います。 -
%wと%vの違い:%vではチェーンが切れ、errors.Isが機能しなくなります。 -
nil な具象型を
errorに入れてしまう: 次のコードはerr != nilが true になります。func doSomething() error {var e *MyError = nilreturn e // error インターフェースに (型=*MyError, 値=nil) が入る。インターフェースとしては nil ではない}エラーなしの場合は必ず
return nilを直接返します。*MyError型の変数をerrorとして返さないでください。 -
例外のようにスタックトレースが付かない: 呼び出し経路は
%wで付けるコンテキストメッセージで追跡します。panic 時のみスタックトレースが出力されます。 -
errors.Asのターゲットは非 nil なポインタ:errors.As(err, ve)のように、変数そのもの (ポインタ型の値) を渡すと panic します。&veのように変数のアドレスを渡します (go vetも検出します)。 -
panicを例外のつもりで使わない: 外部入力や I/O の失敗で panic すると、サーバーが落ちます。 -
defer内のエラー:defer f.Close()はエラーを捨てます。書き込み系のリソースでは結果を確認します。 -
カスタムエラー型はポインタレシーバーが一般的:
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)。