---
title: "Goのエラーハンドリング入門｜if err != nilからerrors.Is/As/AsType・panicの使い分けまで【Go 1.27】"
url: "https://www.issoh.co.jp/tech/details/3895/"
published: 2024-10-21
updated: 2026-11-02
categories: ["Go"]
publisher: "株式会社一創"
---

# Goのエラーハンドリング入門｜if err != nilからerrors.Is/As/AsType・panicの使い分けまで【Go 1.27】

Goのエラーハンドリングは、例外を投げずに `error` 型の値を戻り値で返し、呼び出し側が `if err != nil` で確かめる、という仕組みだけでできています。文法は単純ですが、実務で迷うのは運用です。`:=` で `err` を取り違えない書き方、`%w` でラップする場所、`errors.Is` と `errors.As`（Go 1.26で追加された `errors.AsType`）の使い分け、panic を許す範囲を、Go 1.27.2（darwin/amd64）で実行した結果と一緒に整理します。掲載したコードは import などを省いた断片を含みますが、必要な宣言を補って `go run` と `go vet` で検証し、出力はその実行結果です。誤用例は、意図して起こしたコンパイルエラー・panic・vetの指摘をそのまま載せています。

## まとめ：Goのエラーハンドリングの要点

- **`if err != nil` は普通のif文です**。失敗したら文脈を足してすぐ返し、正常系を左端に並べます。
- **内側のブロックで `:=` を書くと別の `err` ができます**。外側の `err` がnilのまま返っても、コンパイラも `go vet` も報告しません。戻り値の `error` を丸ごと捨てた呼び出しも同じで、errcheck（golangci-lint の既定）で拾います。
- **ラップは `%w` で行います**。`%v` で埋め込むと `errors.Is` がfalseになります。Go 1.27の `go vet` は、`%w` にエラー型のポインタを誤って渡した呼び出しも報告します。
- **値の一致は `errors.Is`、型の取り出しは `errors.AsType`**。`errors.As` の引数の誤りは実行時panicですが、`errors.AsType` はコンパイルで落ち、実測で約9倍速くアロケーションも0でした。
- **panic は想定内の失敗には使いません**。パッケージ内部のエラー伝播に使うpanicだけを、型で見分けて境界で error に変えます。別のgoroutineのpanicは呼び出し元でrecoverできません。
- **書き込み用ファイルの `Close()` の戻り値は捨てません**。名前付き戻り値と `errors.Join` で返します。
- **`if err != nil` を省く新しい構文は来ません**。Goチームは2025年6月に構文変更を当面追わないと表明しており、判定を減らすのは設計（errWriter・`cmp.Or`）の役目です。

## Goのエラーは戻り値で返す値

### errorインターフェースと自作エラー型

`error` は `Error() string` だけを持つ組み込みのインターフェースです。関数は成功したらnil、失敗したらnil以外を返す約束で、`err != nil` はその約束を見ているだけです。仕組みは[Goのインターフェースとは？暗黙実装とダックタイピング・型制約を実装例で解説](/tech/details/3920/)で扱っています。

```
type error interface {
	Error() string
}

type ValidationError struct {
	Field string
}

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

呼び出し側でフィールドを読みたいエラーは、文字列だけでなく構造体にします。レシーバをポインタにした場合、`error` を満たすのは `*ValidationError` で、`ValidationError` ではありません。この区別が後の `errors.As` / `errors.AsType` の書き方を決めます。

### if err != nil の2つの書き方と早期リターン

Go Wikiの[CodeReviewComments](https://go.dev/wiki/CodeReviewComments)は「Do not discard errors using \_ variables.」とし、処理する・返す・本当に例外的な場合だけpanicする、の3択を示しています。同じ文書の「Indent Error Flow」は、エラー処理を先に書いて抜け、正常系の字下げを浅く保つよう求めています。

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

	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("設定 %s のJSONが不正: %w", path, err)
	}
	return &cfg, nil
}
```

```
設定の読み込み: open missing.json: no such file or directory
fs.ErrNotExist: true
設定 bad.json のJSONが不正: invalid character 'p' looking for beginning of object key string
```

2つの書き方の違いは `err` が見える範囲です。

| 書き方                           | errのスコープ    | 向く場面        |
| ----------------------------- | ----------- | ----------- |
| `data, err := f()` の次行で判定     | 同じブロックの末尾まで | 成功時の値を後で使う  |
| `if err := f(); err != nil {` | if文の中だけ     | 戻り値がerrorだけ |

`os.ReadFile` の `data` は次の行で使うので代入してから判定し、`error` しか返さない `json.Unmarshal` は初期化文の形でスコープを閉じています。失敗時に返す第1戻り値はゼロ値（ここでは `nil`）にそろえます。作りかけの値を返すと、呼び出し元がエラーを見落としたときに壊れた値が先へ流れます。

## :=の再宣言とシャドーイングでerrを取り違える落とし穴

### 同じブロックでの再宣言ルール

1つの関数で `x, err := …` を何度も書けるのは、[言語仕様](https://go.dev/ref/spec#Short%5Fvariable%5Fdeclarations)の再宣言ルールのおかげです。条件は「originally declared earlier in the same block … with the same type, and at least one of the non-blank variables is new」で、同じブロックで宣言済みかつ左辺に新しい変数が1つ以上あるときだけ、`err` は上書きされます。新しい変数が無ければコンパイルエラーです。

```
f, err := os.Open("a.txt")
f, err := os.Open("b.txt")
```

```
./main.go:7:9: no new variables on left side of :=
```

[Goブログの2025年6月の記事](https://go.dev/blog/error-syntax)は、このルールがエラー処理のために導入されたと書いています。

### 内側のブロックのシャドーイングとshadowによる検出

「同じブロック」を外れると、`:=` は外側の `err` を隠す新しい変数を作ります。

```
func run(retry bool) error {
	n, err := step1()
	if err != nil {
		return err
	}
	if retry {
		n, err := step2(n) // 内側のブロックで n と err を新しく宣言してしまう
		_ = n
		if err != nil {
			fmt.Println("inner err:", err)
		}
	}
	return err // 外側の err（nil）が返る
}
```

```
inner err: step2 failed
run: <nil>
```

内側では失敗を見ているのに、関数はnilを返しています。コンパイルは通り、`go vet` も何も出しません。既定の解析にシャドーイングの検査が入っていないためです。`golang.org/x/tools` の shadow アナライザー（v0.52.0）を別に実行すると見つかります。

```
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@v0.52.0
shadow ./...
```

```
main.go:19:3: declaration of "n" shadows declaration at line 14
main.go:19:6: declaration of "err" shadows declaration at line 14
```

直し方は、内側を `n, err = step2(n)` の代入にするか、内側で失敗したらその場で `return` することです。shadowは意図した再宣言も報告するので、CIの必須にするより、レビュー時に疑わしい箇所を探す道具に向きます。

### 戻り値のerrorを捨てた呼び出しとerrcheck

受け取った `err` を一度も使わなければ「declared and not used」でコンパイルが止まりますが、`err` を受け取らずに呼んだ場合は何も言われません。次の2行はコンパイルも `go vet` も通ります。

```
f.WriteString("hello\n") // 戻り値の error を捨てている
f.Close()                // 同上
```

これを拾うのが [errcheck](https://github.com/kisielk/errcheck) です。v1.30.0（2026年10月10日公開）の出力です。

```
main.go:10:15:	f.WriteString("hello\n") // 戻り値の error を捨てている
main.go:11:9:	f.Close()                // 同上
```

[golangci-lint v2.15.0](https://github.com/golangci/golangci-lint/releases/tag/v2.15.0)（2026年10月16日公開）は、設定を変えなければ errcheck・govet・ineffassign・staticcheck・unused の5つが有効で、errcheck を含みます。errcheck は空識別子 `_` への代入を既定では検査しないので、捨てた箇所まで洗い出すときは `errcheck -blank ./...` で実行します。

## fmt.Errorfの%wによるラップとerrors.Joinによる集約

### %wと%vによるerrors.Isの判定結果の違い

文脈を足して上へ返すとき、`%w` はラップしてチェーンを残し、`%v` は文字列として埋め込むだけです。メッセージが同じでも判定は逆になります。

```
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
```

ログで見ると両方とも正しく見えるので、テストでも見落とされます。下位のエラーを呼び出し元に判定させたいなら `%w` です。内部実装の型を公開APIに漏らしたくないときだけ、意図して `%v` を選びます。

### errors.Joinによる複数エラーの集約

Go 1.20で追加された `errors.Join` は、複数のエラーを1つの `error` にします。入力チェックで全件の誤りを返したい場面や、後で説明する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` はtrueです。文字列は**改行区切り**になるので、1行1レコードのログ基盤にそのまま流すと記録が割れます。同じGo 1.20から `fmt.Errorf` も `%w` を複数書けます。

### ラップ時のメッセージ二重化

標準ライブラリの多くは、操作名と対象を含むエラーを返します。`os.Open` の `*fs.PathError` は「open <path>: …」の形なので、呼び出し側でパスを足すと重複します。

```
// 修正前：os.Open のエラーが既に "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
```

ラップで足すのは、下位が知らない情報（何のために開いたか）だけにします。各層がこの線を守れば、層を重ねても同じ語が繰り返されず、原因まで左から右へ読めます。

### Go 1.27のvetが報告する%wへのポインタ渡し

[Go 1.27のリリースノート](https://go.dev/doc/go1.27#vet)によると、vetのprintf解析は、型 `E` 自体が `error` を実装しているのに `%w` へ `*E` を渡した呼び出しを報告するようになりました。値レシーバで `Error()` を書いた型を `&` 付きで包むと、型で探す判定が静かに外れます。

```
type QuotaError struct{ Limit int }

func (e QuotaError) Error() string { return fmt.Sprintf("quota exceeded: %d", e.Limit) }

func check(n int) error {
	if n > 10 {
		qe := &QuotaError{Limit: 10}
		return fmt.Errorf("check: %w", qe) // 値型で error を実装した型のポインタを包んでいる
	}
	return nil
}
```

```
$ go run .
check: quota exceeded: 10
AsType[QuotaError] : false
AsType[*QuotaError]: true
Is(QuotaError{10}) : false
$ go vet .
main.go:15:29: %w wants operand of error type QuotaError, not pointer type *QuotaError (defeats errors.Is)
```

呼び出し元が `QuotaError` で判定すると、メッセージは正しく出ているのに一致しません。`go test` が既定で走らせるvetにprintf検査は含まれるので、Go 1.27に上げてテストを回すと既存コードのこの誤りが表に出ます。直すときは `&` を外して値で包みます。Go 1.27のほかの変更は[Go 1.27の新機能・変更点まとめ｜Goの最新バージョンは1.27、確認・更新方法と移行の注意点](/tech/details/10416/)にまとめています。

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

### 値の一致を見るerrors.Isと型を取り出すerrors.As

「このエラーか？」を訊くのが `errors.Is`、「この型として中身を読みたい」が `errors.As` です。どちらもチェーンを辿ります。

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

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

### errors.Asの引数の誤りによる実行時panic

`errors.As` の第2引数は `any` 型なので、型の整合はコンパイラが検査しません。`&` を取り忘れて値を渡してもビルドは通り、実行時に落ちます。

```
var ve ValidationError
fmt.Println(errors.As(err, ve)) // ポインタを取り忘れている
```

```
panic: errors: target must be a non-nil pointer
```

見つけられるのは `go vet` です。

```
main.go:19: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に入っています。逆に言えば、vetもテストもCIに無いプロジェクトでは本番まで残ります。エラー経路は成功ケースのテストでは通らないので、静的検査に加えて、失敗を再現するテストも用意します。

### errors.AsType（Go 1.26）のコンパイル時検査と実測値

[Go 1.26のリリースノート](https://go.dev/doc/go1.26#errors)は、新しい `AsType` を「a generic version of As. It is type-safe, faster, and, in most cases, easier to use」と説明しています。[シグネチャ](https://pkg.go.dev/errors#AsType)は `func AsType[E error](err error) (E, bool)` です。

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

型引数に値型 `ValidationError` を書くと、実行前にコンパイラが止めます。

```
./main.go:18:29: ValidationError does not satisfy error (method Error has pointer receiver)
```

3段ラップしたエラーに対し、`errors.As` はループの中で受け取り変数を宣言し、`errors.AsType` は成否だけを使う形で、`-benchmem -benchtime=2s -count=5` を取った結果です（Go 1.27.2 / Intel Core i9-9880H）。

| 関数            | ns/op       | B/op | allocs/op |
| ------------- | ----------- | ---- | --------- |
| errors.As     | 249.1〜257.8 | 8    | 1         |
| errors.AsType | 27.68〜29.23 | 0    | 0         |

8バイトの確保は、`&ve` を `any` として渡した受け取り変数がヒープへ逃げるためです。変数をループの外で1回だけ宣言すると、別の計測で確保は0になりましたが、時間は330〜342ns/opで `errors.AsType`（同じ計測で42〜43ns/op）の約8倍のままでした。差の大半は、`errors.As` がリフレクションで引数の型を調べる処理です。**Go 1.26以降を前提にできるコードでは `errors.AsType` を既定にしてよい**と判断します。`errors.As` を残すのは、Go 1.25以前でもビルドされるライブラリを書くときと、`interface{ Timeout() bool }` のように `error` を含まないインターフェースで探したいときです（`AsType` では `interface{ error; Timeout() bool }` と `error` を埋め込めば同じ判定ができました）。[リリースポリシー](https://go.dev/doc/devel/release#policy)では各版は新しい版が2つ出るまでサポートされ、2026年11月時点の現行は1.27.3と1.26.10なので、サポート中の版はどちらも `AsType` を使えます。

## panicとrecoverを使ってよい境界

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

panic は例外の代わりではありません。範囲外アクセス・nil参照・整数のゼロ除算ではランタイムがpanicを起こします。ファイルが無い、入力が不正、通信が切れた、は想定内の失敗なので `error` で返します。一方で標準ライブラリには、深い再帰の途中から抜けるためにpanicを内部のエラー伝播に使う実装もあります。`encoding/json` のエンコーダは、自分の型 `jsonError` だけをrecoverして `error` に戻し、それ以外は `panic(r)` で投げ直しています（[encode.go](https://go.dev/src/encoding/json/encode.go)）。recoverで変換するのは、このように自分で起こしたと分かるpanicに絞ります。下の `safeDivide` は、変換の書き方を見るための最小例です。

```
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
}

// 戻り値に名前を付けないと、defer での代入が呼び出し元に届かない
func unnamed(a, b int) (int, error) {
	var err error
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("unnamed: %v", r)
		}
	}()
	return a / b, err
}
```

```
5 <nil>
0 safeDivide: runtime error: integer divide by zero
0 <nil>
```

3行目が `unnamed(10, 0)` です。panicは止まりましたが `err` はnilのまま返り、呼び出し元は失敗に気づけません。recoverで変換するときは、戻り値に名前を付けてdefer内で代入します。なお Go 1.21以降の標準設定では、`panic(nil)` を回復した `recover` は nil ではなく [\*runtime.PanicNilError](https://pkg.go.dev/runtime#PanicNilError) を返すので、`r != nil` の判定で取りこぼしません（`GODEBUG=panicnil=1` で旧動作に戻した場合はnilです）。

### 別goroutineのpanicとrecoverの適用範囲

recover は、panicしたのと**同じgoroutine**のdeferでしか効きません。

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

```
panic: worker failed

goroutine 19 [running]:
main.main.func2()
	/tmp/main.go:9 +0x25
created by main.main in goroutine 1
	/tmp/main.go:9 +0x37
exit status 2
```

main のrecoverは一度も呼ばれず、プロセスごと終了しました（goroutine番号は実行ごとに変わります）。HTTPハンドラにrecoverを入れていても、そこから起動したgoroutineのpanicでサーバーは落ちます。回収するには、起動するgoroutineの内側にdeferを置きます。

```
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は生き残ります。ただし表示して終わりにすると、失敗が誰にも伝わりません。共通ヘルパーでrecoverするなら、エラーとして呼び出し元へ返し、スタックをログに残します。共有状態を書き換えている途中のpanicのように整合性を保証できないものは、回収せずにプロセスを止めるほうが安全です。goroutineの仕組みは[ゴルーチン（goroutine）とは？仕組み・使い方と同時実行数の制限をGo 1.27で解説](/tech/details/4632/)で扱っています。

## deferのClose失敗と名前付き戻り値による伝播

書き込み用のファイルでは、OSやファイルシステム（NFSなど）によって、書き込みの失敗が `Close` の時点で報告されることがあります。`defer f.Close()` で捨てると、成功扱いなのにデータが欠けます。`bufio.Writer` を挟む場合は、`Close` の前に `Flush` を呼んでその戻り値も確かめます。

```
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
}
```

Closeの失敗を作るために、同じ形の関数の中で先に1回 `f.Close()` させた結果です。

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

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

## errors.Isによるリトライ判定とcontextによる打ち切り

外部APIの呼び出しでは、再試行してよい失敗と無意味な失敗を分けます。判定をセンチネルとの `errors.Is` で書くと、ラップの深さに左右されません。

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

func withRetry(ctx context.Context, max int) error {
	if max < 1 {
		return fmt.Errorf("max must be positive: %d", max)
	}
	var last error
	wait := 100 * time.Millisecond
	for attempt := 1; attempt <= max; attempt++ {
		if err := ctx.Err(); err != nil { // 試行の前にキャンセルを確認
			return errors.Join(last, err)
		}
		last = callAPI(attempt)
		if last == nil {
			return nil
		}
		if !retryable(last) {
			return last
		}
		if attempt == max { // 最後の試行の後は待たない
			break
		}
		select {
		case <-ctx.Done():
			return errors.Join(last, ctx.Err())
		case <-time.After(wait):
		}
		wait = min(wait*2, 30*time.Second) // 待機は100msから倍々で上限30秒
	}
	return fmt.Errorf("giving up after %d attempts: %w", max, last)
}
```

`callAPI` は3回目で成功する代役で、この例のcontextは試行前の確認と待機の中断に使っています。実際の外部APIでは `callAPI(ctx, attempt)` のようにcontextを渡し、通信中のキャンセルも伝えます。上限2回で打ち切った場合と、50msのタイムアウトで中断した場合の出力です。

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

中断時に `ctx.Err()` だけを返すと、レート制限が原因だった事実が消えます。`errors.Join` で両方を残せば、「レート制限が続いた末にタイムアウトした」経緯が1つの値から読めます。待機を `time.Sleep` でなく `select` で書くのは、キャンセルを待機中にも受け取るためです。contextは[Goのcontext入門｜context.Background()とTODOの違い、WithCancel・WithTimeoutの実装【Go 1.27】](/tech/details/3405/)、エラー文の読み分けは[Goの「context deadline exceeded」「context canceled」の原因と対処｜エラー文で発生箇所を見分ける【Go 1.27】](/tech/details/3757/)で扱っています。

## ラップを辿らない標準ヘルパーと型付きnil

### fmt.Errorfでラップしたエラーとos.IsNotExistの判定

`os.IsNotExist` と `os.IsPermission` は `errors.Is` より古い関数です。[ドキュメント](https://pkg.go.dev/os#IsNotExist)には「It only supports errors returned by the os package.」とあり、`*fs.PathError` のようなosパッケージのエラーは中を見ますが、`fmt.Errorf` の `%w` で包んだチェーンは辿りません。

```
_, rawErr := os.Open("/tmp/missing.json")
err := fmt.Errorf("設定の読み込み: %w", rawErr)

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

コンパイルエラーにもpanicにもならず、分岐だけが変わります。既存コードにラップを入れるときは、`errors.Is(err, fs.ErrNotExist)` / `errors.Is(err, fs.ErrPermission)` への置き換えを同時に行います。

### 型付きnilとerrorインターフェースのnil判定

インターフェースの値は「型と値」の組なので、値がnilでも型が入っていれば `== nil` はfalseです。

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

// 正しい：成功時は return 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` がfalseという、追いにくい状態です。戻り値の型を具体型のポインタにせず `error` にし、成功時は変数を経由せず `return nil` と書きます。

## if err != nilを減らす実装パターン

### errWriterによる最初のエラーの保持と判定の集約

Rob Pikeの[Errors are values](https://go.dev/blog/errors-are-values)（2015年）の考え方で、同じ種類の操作を続ける処理では、最初のエラーを構造体に覚えさせ以降を何もしないようにします。

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

func (ew *errWriter) printf(format string, args ...any) {
	if ew.err != nil {
		return
	}
	_, ew.err = fmt.Fprintf(ew.w, format, args...)
}

func writeReport(w io.Writer, rows []string) error {
	ew := &errWriter{w: w}
	ew.printf("# report\n")
	for i, r := range rows {
		ew.printf("%d,%s\n", i+1, r)
	}
	ew.printf("# total %d\n", len(rows))
	return ew.err // 判定は最後の1回だけ
}
```

10バイト書いた後に「disk full」を返す `Writer` を渡すと、以降の `printf` は何もせず、最初のエラー「disk full」だけが返りました。`bufio.Scanner` も同じ設計で、ループを抜けた後に `Err()` を1回呼びます。呼び忘れると読み込み途中の失敗が正常終了に見えます。

### cmp.Orによる一括判定と使ってはいけない場面

Go 1.22で追加された `cmp.Or` は、引数のうちゼロ値でない最初のものを返します。`error` のゼロ値はnilなので、複数の `err` を1回で判定できます。

```
x, err1 := strconv.Atoi(a)
y, err2 := strconv.Atoi(b)
if err := cmp.Or(err1, err2); err != nil {
	return err
}
```

```
result: 3
<nil>
strconv.Atoi: parsing "x": invalid syntax
```

2回目は `"x"` と `"y"` の両方が不正ですが、返ったのは先頭の失敗だけです。もう1つの注意は、`cmp.Or` を呼ぶ前に全部の呼び出しが実行されることです。失敗したら後続の副作用（DB書き込みや外部API）を起こしてはいけない処理、前の結果を次の入力に使う処理では、呼び出しごとに `if err != nil` で抜けてください。全部の失敗を残したいなら `errors.Join` を使います。

## check/handle・try・?の構文提案が見送られた経緯

`if err != nil` を構文で省く案は、Goチーム自身が3回出し、どれも取り下げています。経緯はRobert Griesemerの[\[ On | No \] syntactic support for error handling](https://go.dev/blog/error-syntax)（2025年6月3日）にまとまっています。

| 時期      | 案                      | 書き方              | 結果              |
| ------- | ---------------------- | ---------------- | --------------- |
| 2018年8月 | check / handle（ドラフト設計） | `x := check f()` | 複雑すぎるとして撤回      |
| 2019年6月 | try 組み込み関数（#32437）     | `x := try(f())`  | 900件近いコメントの末に断念 |
| 2025年1月 | ? 演算子（#71203）          | `x := f() ?`     | 提案を閉じ議論へ移行      |
| 2025年6月 | Goチームの方針表明             | なし               | 構文変更を当面追わない     |

tryが退けられた理由としてブログは、深い式の中からでも関数を抜けてしまい制御の流れが見えなくなる点を挙げています。Ian Lance Taylorの [?演算子の提案（#71203）](https://github.com/golang/go/issues/71203)は、小規模な非公式のユーザー調査では大多数の参加者が意味を正しく推測できたものの、幅広い支持には至りませんでした。結論は「For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling.」で、主にエラー処理の構文を扱う提案は今後検討せずに閉じるとしています。`if err != nil` を置き換える構文を待つ理由はありません。

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

はっきり言えるのは、**発生した層ごとにログを出してはいけない**ことです。各層で出すと、1件の障害が呼び出しの深さの数だけログに並び、件数から深刻度を読めなくなります。ログはHTTPハンドラ・ワーカーのループ・mainといった最上位で1回だけ出します。

- **その層で回復できるなら処理する**。キャッシュに無ければ元データを取りに行く、のように代わりの経路がある場合です。
- **回復できないなら文脈を足して返す**。ログもリトライもせず、`fmt.Errorf` で1語足して `%w` で返します。
- **利用者への表示は最上位で組み立てる**。内部のパスやSQLを返さないよう、境界で `errors.Is` / `errors.AsType` で分類して文言に変えます。

この分担なら、エラーは「最上位の文脈 → 中間の文脈 → 根本原因」の順の1行になり、ログ検索で原因まで辿れます。Goで業務システムを新しく組む際の、エラー設計を含む設計・実装は[フルスクラッチ開発](/service/system/fullscratch/)で支援しています。他の言語の例外処理との比較は[エラーハンドリングとは？例外処理のベストプラクティスとtry-catch-finallyの言語別の違い](/tech/details/4348/)を参照してください。

## よくある質問

### Goにtry-catch（例外処理）が無いのはなぜですか？

エラーを通常の戻り値として扱い、失敗しうる箇所をコード上に見える形で残す設計だからです。例外では、どの関数が何を投げるかがシグネチャに出ず、捕捉漏れに気づけません。Goは `(T, error)` でそれを型に載せています。panic と recover は例外の代わりではなく、プログラムのバグを知らせる仕組みです。

### errを\_で捨ててもよいのはどんなときですか？

仕様でnilが保証されている場合と、失敗しても結果に影響しない理由を説明できる場合です。前者の例が `bytes.Buffer` の `Write` で、公式ドキュメントに「err is always nil」とあります。後者の例が読み取り専用で開いたファイルの `Close` です。書き込み用ファイルの `Close` のように遅れて失敗を返すものは捨ててはいけません。捨てた箇所は `errcheck -blank` で洗い出せます。

### golangでnew errorはどう作りますか？

`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` に置き換えられ、型の誤りがコンパイル時に見つかります。

### Goのpanicはどんなときに使ってよいですか？

続けても意味がない、プログラム側のバグに限ります。起動時に必須の設定が無い、初期化に失敗した、といった場面です。`regexp.MustCompile` のように、固定値の入力で失敗したら即panicする `Must` 系関数も慣習です。ライブラリ内部でエラー伝播に使うpanicは、専用の型に限定して境界でrecoverし `error` に変えます。想定外のpanicまで一律に回収する必要はありません。

## 関連記事

- [Go 1.27の新機能・変更点まとめ｜Goの最新バージョンは1.27、確認・更新方法と移行の注意点](/tech/details/10416/)
- [Go言語とは｜1.27系の導入からHTTP APIを動かすまでの実装入門](/tech/details/17483/)
- [Goのcontext入門｜context.Background()とTODOの違い、WithCancel・WithTimeoutの実装【Go 1.27】](/tech/details/3405/)
- [ゴルーチン（goroutine）とは？仕組み・使い方と同時実行数の制限をGo 1.27で解説](/tech/details/4632/)
- [エラーハンドリングとは？例外処理のベストプラクティスとtry-catch-finallyの言語別の違い](/tech/details/4348/)

---

出典: [Goのエラーハンドリング入門｜if err != nilからerrors.Is/As/AsType・panicの使い分けまで【Go 1.27】](<https://www.issoh.co.jp/tech/details/3895/>)（株式会社一創）
