- Go言語
Goエラーハンドリング|errors.Is/As・wrapの使い分け実例集
Goのエラーハンドリングとは、失敗しうる関数が最後の戻り値として error を返し、呼び出し側が if err != nil で受け取って「文脈を足して返す」「種類で分岐する」「記録する」のいずれかを行う仕組みです。 例外機構はなく、エラーはただの値なので、fmt.Errorf("...: %w", err) で包み、errors.Is/errors.As で連鎖をたどって判定します。
この記事では、error インターフェースの基本から、センチネルエラー・カスタムエラー型・ラップのどれを選ぶかの判断表とフロー、errors.Is/errors.As/errors.AsType(Go 1.26 で追加)/errors.Join/複数の %w/errors.Unwrap の実例、panic/recover の使いどころ、HTTP ハンドラでのステータスコード変換と log/slog での記録、型付き nil やシャドウイングなどよくある間違い、errors.Is を使ったテーブル駆動テストまでを、すべて Go 1.27.1 で実行した結果つきで解説します。
Goのエラーハンドリングの早見表
| やりたいこと | 書き方 | 補足 |
|---|---|---|
| エラーを作る | errors.New("msg") / fmt.Errorf("id=%d: msg", id) |
error は Error() string を持つだけのインターフェース |
| 文脈を足して包む | fmt.Errorf("loadItem(%s): %w", id, err) |
%w で元のエラーをたどれる。%v だと文字列になるだけ |
| 種類で分岐する | errors.Is(err, ErrNotFound) |
センチネルエラー(パッケージ変数)を連鎖の中から探す |
| 値を取り出す | var ve *ValidationError; errors.As(err, &ve) |
カスタムエラー型を連鎖の中から探す |
| 値を取り出す(1.26〜) | ve, ok := errors.AsType[*ValidationError](err) |
ジェネリック版。ポインタ変数の準備が不要で型安全 |
| 複数のエラーをまとめる | errors.Join(errs...) / fmt.Errorf("%w; %w", e1, e2) |
Go 1.20〜。Is/As はすべての枝を探す |
| 1段だけほどく | errors.Unwrap(err) |
Join したエラーには nil を返す(Unwrap() []error を使う) |
| 自作型を連鎖に参加させる | func (e *E) Unwrap() error { return e.Err } |
これで Is/As が内側までたどる |
| 復旧不能な状態で止める | panic("config key must not be empty") |
通常のエラーには使わない。起動時の設定不備など |
| panic を error に変換 | defer func() { if r := recover(); r != nil { err = fmt.Errorf("recovered: %v", r) } }() |
recover は defer した関数の中でだけ効く |
| HTTP のステータスに変換 | switch { case errors.Is(err, ErrNotFound): 404 ... } |
変換は 1 か所にまとめ、内部の詳細は返さない |
| 構造化ログに記録 | slog.Error("request failed", "err", err) |
エラー値をそのまま渡す |
| テストで検証 | if !errors.Is(err, tt.wantErr) { t.Errorf(...) } |
文字列比較ではなく Is/AsType で比較 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Goエラーハンドリング|errors.Is/As・wrapの使い分け実例集」
errorインターフェースの基本と、%wと%vの違い
error は Error() string メソッドを1つ持つだけのインターフェースで、errors.New や fmt.Errorf が返す値もこれを満たす普通の値です。 大事なのは fmt.Errorf の書式で、%w は元のエラーを「包んで」後からたどれるようにし、%v は文字列として埋め込むだけという違いがあります。
package main
import (
"errors"
"fmt"
"strconv"
)
// error は「Error() string を持つ」だけのインターフェース
// type error interface { Error() string }
func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
// %w:元のエラーを「包む」。後から errors.Is / As / Unwrap でたどれる
return 0, fmt.Errorf("parsePort(%q): %w", s, err)
}
if n < 1 || n > 65535 {
return 0, errors.New("port out of range") // 新しいエラーを作る
}
return n, nil
}
func main() {
_, err := parsePort("80a")
fmt.Println("message :", err)
fmt.Printf("type : %T\n", err)
fmt.Println("unwrap :", errors.Unwrap(err)) // 包まれた元のエラー
fmt.Println("is Err? :", errors.Is(err, strconv.ErrSyntax))
// %v で作ると「文字列として埋め込む」だけで、元のエラーはたどれない
base := errors.New("base error")
wrapped := fmt.Errorf("with %%w: %w", base)
formatted := fmt.Errorf("with %%v: %v", base)
fmt.Println(wrapped, "->", errors.Unwrap(wrapped) != nil, errors.Is(wrapped, base))
fmt.Println(formatted, "->", errors.Unwrap(formatted) != nil, errors.Is(formatted, base))
_, err = parsePort("70000")
fmt.Println("message :", err)
fmt.Println("unwrap :", errors.Unwrap(err)) // 包んでいないので nil
}
$ go run .
message : parsePort("80a"): strconv.Atoi: parsing "80a": invalid syntax
type : *fmt.wrapError
unwrap : strconv.Atoi: parsing "80a": invalid syntax
is Err? : true
with %w: base error -> true true
with %v: base error -> false false
message : port out of range
unwrap : <nil>
| 書式 | 表示される文字列 | errors.Unwrap |
errors.Is/As で元をたどれる |
|---|---|---|---|
fmt.Errorf("...: %w", err) |
同じ | 元のエラー | たどれる |
fmt.Errorf("...: %v", err) |
同じ | nil |
たどれない |
errors.New("...") |
固定文字列 | nil |
(包んでいない) |
迷ったら %w で包みます。 表示される文字列は同じで、判定の手段が増えるだけだからです。あえて %v にするのは「内部の実装エラーを外に見せたくない(境界で意図的に切る)」場面に限られます。
センチネルエラー・カスタムエラー型・ラップの使い分け
エラーを返す側の設計は「呼び出し側が何をしたいか」で決まります。 種類だけ判定して分岐したいならセンチネルエラー、フィールドの値(どの項目か・いくつ足りないか)を使いたいならカスタムエラー型、判定は不要で文脈を足すだけならラップです。
| 方法 | 書き方 | 呼び出し側の判定 | 向いている場面 | 注意点 |
|---|---|---|---|---|
| センチネルエラー | var ErrNotFound = errors.New("item: not found") |
errors.Is(err, ErrNotFound) |
「見つからない」「重複」など種類で分岐したい | 値を持てない。公開すると API の一部になる |
| カスタムエラー型 | type ValidationError struct{ Field, Reason string } + Error() |
errors.As / errors.AsType |
どの項目・どの値で失敗したかを使いたい | ポインタで返すときは「型付き nil」に注意 |
| ラップ | fmt.Errorf("loadItem(%s): %w", id, err) |
そのまま上に返す | 各層で文脈(関数名・ID)を足す | %v にすると連鎖が切れる |
| まとめる | errors.Join(errs...) |
errors.Is/As(全枝を探す) |
検証を最後まで実行して全部返したい | errors.Unwrap は nil を返す |
flowchart TD
A[エラーを返したい] --> B{呼び出し側は<br>何をしたい?}
B -->|種類だけで分岐したい| C[センチネルエラー<br>var ErrX = errors.New]
B -->|フィールドの値を使いたい| D[カスタムエラー型<br>type XError struct + Error]
B -->|判定は不要・文脈を足すだけ| E[fmt.Errorf の %w でラップ]
B -->|複数の失敗を全部返したい| F[errors.Join]
C --> G[判定は errors.Is]
D --> H[取り出しは errors.As / AsType]
E --> I[上位層で Is / As がそのまま使える]
F --> G
F --> H
3つは排他ではなく組み合わせます。 下の層がセンチネルやカスタム型を返し、中間の層は %w で文脈を足して返し、いちばん上の層で errors.Is/As で分岐する、というのが典型的な形です。
errors.Is:センチネルエラーを連鎖の中から判定する
errors.Is(err, target) は、err を Unwrap でたどりながら target と等しいものがあるかを調べます。 何段包まれていても判定できるので、== の代わりに常に errors.Is を使います。
package main
import (
"errors"
"fmt"
)
// センチネルエラー:パッケージ変数として公開する「決まったエラー値」
var (
ErrNotFound = errors.New("item: not found")
ErrConflict = errors.New("item: already exists")
)
type Item struct{ ID, Name string }
// 層1:データ取得
func findItem(id string) (*Item, error) {
if id != "A1" {
return nil, ErrNotFound
}
return &Item{ID: id, Name: "ノート"}, nil
}
// 層2:業務処理。文脈を足して包み直す
func loadItem(id string) (*Item, error) {
it, err := findItem(id)
if err != nil {
return nil, fmt.Errorf("loadItem(%s): %w", id, err)
}
return it, nil
}
// 層3:さらに包む
func handle(id string) error {
if _, err := loadItem(id); err != nil {
return fmt.Errorf("handle: %w", err)
}
return nil
}
func main() {
err := handle("B2")
fmt.Println("message :", err)
fmt.Println("err == ErrNotFound:", err == ErrNotFound) // 包まれているので false
fmt.Println("errors.Is :", errors.Is(err, ErrNotFound)) // 連鎖をたどって true
fmt.Println("errors.Is other :", errors.Is(err, ErrConflict))
// 連鎖を1段ずつたどる
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" %T: %v\n", e, e)
}
}
$ go run .
message : handle: loadItem(B2): item: not found
err == ErrNotFound: false
errors.Is : true
errors.Is other : false
*fmt.wrapError: handle: loadItem(B2): item: not found
*fmt.wrapError: loadItem(B2): item: not found
*errors.errorString: item: not found
標準ライブラリにも os.ErrNotExist、io.EOF、context.Canceled、context.DeadlineExceeded、sql.ErrNoRows などのセンチネルがあり、同じように errors.Is で判定します。
errors.As と errors.AsType:カスタムエラー型の値を取り出す
errors.As(err, &target) は、連鎖の中から「target の型に代入できるエラー」を探して target に入れます。 Go 1.26 で追加された errors.AsType[T](err) はそのジェネリック版で、ポインタ変数をあらかじめ宣言する必要がなく、型の間違いがコンパイル時に分かります。
package main
import (
"errors"
"fmt"
)
// カスタムエラー型:呼び出し側が「値」を取り出したいときに使う
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s %s", e.Field, e.Reason)
}
// Unwrap を実装すると、さらに内側のエラーを持てる
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return "query " + e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
var ErrTimeout = errors.New("timeout")
func validate(qty int) error {
if qty <= 0 {
return &ValidationError{Field: "qty", Reason: "must be positive"}
}
return nil
}
func save() error {
return fmt.Errorf("save: %w", &QueryError{Query: "INSERT items", Err: ErrTimeout})
}
func main() {
err := fmt.Errorf("register: %w", validate(0))
fmt.Println(err)
// errors.As:連鎖の中から「その型」の値を取り出す(Go 1.13〜)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("As : field =", ve.Field, "/ reason =", ve.Reason)
}
// errors.AsType:ジェネリック版。ポインタ変数の準備が不要(Go 1.26〜)
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("AsType : field =", ve.Field)
}
// Unwrap を持つカスタム型は、Is / As の両方が中までたどる
err2 := save()
fmt.Println(err2)
fmt.Println("Is ErrTimeout :", errors.Is(err2, ErrTimeout))
if qe, ok := errors.AsType[*QueryError](err2); ok {
fmt.Println("AsType Query :", qe.Query)
}
_, ok := errors.AsType[*ValidationError](err2)
fmt.Println("AsType (別型) :", ok)
}
$ go run .
register: validation failed: qty must be positive
As : field = qty / reason = must be positive
AsType : field = qty
save: query INSERT items: timeout
Is ErrTimeout : true
AsType Query : INSERT items
AsType (別型) : false
| 観点 | errors.Is |
errors.As |
errors.AsType(1.26〜) |
|---|---|---|---|
| 何を探すか | 「同じ値」のエラー | 「代入できる型」のエラー | 同左(型パラメータで指定) |
| 対象 | センチネルエラー | カスタムエラー型 | カスタムエラー型 |
| 使い方 | errors.Is(err, ErrX) |
var e *XErr; errors.As(err, &e) |
e, ok := errors.AsType[*XErr](err) |
| 型の誤りに気づく時点 | ― | 実行時(panic)/go vet |
コンパイル時 |
| 取り出した値 | 使わない(真偽だけ) | e に入る |
戻り値で受け取る |
判断の目安は「if で分岐するだけなら Is、フィールドを読むなら As/AsType」です。 カスタム型を *ValidationError のようにポインタで返しているなら、As の第2引数は **ValidationError(&ve)、AsType の型引数は *ValidationError になります。
errors.Join と複数の %w:複数のエラーをまとめる
入力検証のように「最後まで実行して見つかった問題を全部返したい」ときは、errors.Join(Go 1.20〜)で1つの error にまとめます。 errors.Is/As はまとめられた全部の枝を探すので、呼び出し側の書き方は変わりません。
package main
import (
"errors"
"fmt"
)
var (
ErrNameEmpty = errors.New("name is empty")
ErrQtyRange = errors.New("qty out of range")
)
type Order struct {
Name string
Qty int
}
// すべての検証を実行し、見つかった問題をまとめて返す
func validate(o Order) error {
var errs []error
if o.Name == "" {
errs = append(errs, ErrNameEmpty)
}
if o.Qty < 1 || o.Qty > 100 {
errs = append(errs, fmt.Errorf("qty=%d: %w", o.Qty, ErrQtyRange))
}
return errors.Join(errs...) // errs が空なら nil を返す
}
func main() {
err := validate(Order{Name: "", Qty: 0})
fmt.Printf("--- Join (%T) ---\n%v\n", err, err) // 改行区切りで表示される
fmt.Println("Is ErrNameEmpty:", errors.Is(err, ErrNameEmpty))
fmt.Println("Is ErrQtyRange :", errors.Is(err, ErrQtyRange))
fmt.Println("errors.Unwrap :", errors.Unwrap(err)) // 複数を包むエラーには nil を返す
// Unwrap() []error で個々のエラーを取り出せる
if u, ok := err.(interface{ Unwrap() []error }); ok {
for i, e := range u.Unwrap() {
fmt.Printf(" [%d] %v\n", i, e)
}
}
// fmt.Errorf に %w を複数書いても同じように扱える(Go 1.20〜)
err2 := fmt.Errorf("order check: %w; %w", ErrNameEmpty, ErrQtyRange)
fmt.Printf("--- multi %%w (%T) ---\n%v\n", err2, err2)
fmt.Println("Is both:", errors.Is(err2, ErrNameEmpty), errors.Is(err2, ErrQtyRange))
fmt.Println("--- no error ---")
fmt.Println(validate(Order{Name: "pen", Qty: 3}) == nil)
}
$ go run .
--- Join (*errors.joinError) ---
name is empty
qty=0: qty out of range
Is ErrNameEmpty: true
Is ErrQtyRange : true
errors.Unwrap : <nil>
[0] name is empty
[1] qty=0: qty out of range
--- multi %w (*fmt.wrapErrors) ---
order check: name is empty; qty out of range
Is both: true true
--- no error ---
true
| 使い分け | errors.Join(e1, e2, ...) |
fmt.Errorf("%w; %w", e1, e2) |
|---|---|---|
| 表示 | 改行区切り | 書式どおり(1行にできる) |
| 個数 | 可変(スライスを展開) | 書式に書いた数だけ |
nil の扱い |
nil は無視。全部 nil なら nil を返す |
nil を渡すと %!w(<nil>) と表示される |
| 向く場面 | 検証結果の集約、複数リソースの後始末 | 2つのエラーを文脈つきで1行にしたい |
errors.Unwrap は「1段だけほどく」関数で、Unwrap() error を持つエラーにしか効きません。 Join や複数 %w のエラーは Unwrap() []error を持つため nil が返ります。個々に触りたいときは上のように型アサーションで取り出しますが、普段は Is/As に任せれば十分です。
panic と recover の使いどころ
panic は「プログラムをこれ以上続けられない」ときの仕組みで、通常の失敗(入力の誤り・見つからない・タイムアウト)には使いません。 使うのは起動時の設定不備や、到達しないはずのコードに到達したときなどです。recover は defer した関数の中でだけ効き、panic を error に変換して返す形が定番です。
package main
import (
"fmt"
"runtime"
)
// recover は defer した関数の中でだけ効く。panic を error に変換して返す定番形
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // b == 0 なら実行時 panic
}
// 設定ミスなど「起動時に止めるべき」場面では panic を使ってよい
func mustLoad(key string) string {
if key == "" {
panic("config key must not be empty")
}
return "value"
}
func main() {
v, err := safeDiv(10, 2)
fmt.Println(v, err)
v, err = safeDiv(1, 0)
fmt.Println(v, err)
// recover した値の型を調べる:ランタイム起因なら runtime.Error を満たす
func() {
defer func() {
if r := recover(); r != nil {
_, isRuntime := r.(runtime.Error)
fmt.Printf("recovered %T (runtime.Error=%v): %v\n", r, isRuntime, r)
}
}()
var m map[string]int
m["k"] = 1
}()
fmt.Println(mustLoad("app.name"))
fmt.Println("--- recover しない panic はプログラムを終了させる ---")
mustLoad("")
fmt.Println("ここには到達しない")
}
$ go run .
5 <nil>
0 recovered: runtime error: integer divide by zero
recovered runtime.plainError (runtime.Error=true): assignment to entry in nil map
value
--- recover しない panic はプログラムを終了させる ---
panic: config key must not be empty
goroutine 1 [running]:
main.mustLoad(...)
/path/to/main.go:21
main.main()
/path/to/main.go:46 +0x1a7
exit status 2
| 場面 | 使うもの | 理由 |
|---|---|---|
| 入力の誤り・見つからない・タイムアウト | error を返す |
呼び出し側が判断して続行できる |
| 起動時の設定不備(必須の環境変数がない等) | panic または log.Fatal |
続行しても意味がない。regexp.MustCompile の Must 系も同じ発想 |
| 「ここには来ないはず」の分岐 | panic("unreachable: ...") |
バグをすぐ表面化させる |
| HTTP サーバーのハンドラ内で起きた panic | ミドルウェアで recover して 500 を返す |
1リクエストの問題でプロセス全体を止めない(net/http は既定でも recover して接続を閉じる) |
| goroutine 内の panic | その goroutine の中で recover |
別の goroutine の defer では拾えない |
HTTPハンドラでエラーをステータスコードに変換し、slog で記録する
業務ロジックは HTTP を知らずに error を返し、ハンドラ側の1か所で「エラー → ステータスコード」に変換するのが整理しやすい形です。 error を返すハンドラ型を定義すると、変換とログ出力を共通化できます。ログは log/slog にエラー値をそのまま渡します。
package main
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
)
var ErrNotFound = errors.New("item not found")
type ValidationError struct{ Field, Reason string }
func (e *ValidationError) Error() string { return e.Field + " " + e.Reason }
// 業務ロジック:HTTP のことは知らない(エラーを返すだけ)
func getItem(id string) (map[string]any, error) {
n, err := strconv.Atoi(id)
if err != nil {
return nil, &ValidationError{Field: "id", Reason: "must be a number"}
}
if n != 1 {
return nil, fmt.Errorf("getItem(%d): %w", n, ErrNotFound)
}
return map[string]any{"id": n, "name": "ノート"}, nil
}
// エラー → HTTP ステータスの変換は 1 か所にまとめる
func statusOf(err error) (int, string) {
var ve *ValidationError
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound, "not found"
case errors.As(err, &ve):
return http.StatusBadRequest, ve.Error()
default:
return http.StatusInternalServerError, "internal error" // 内部の詳細は返さない
}
}
// error を返すハンドラ型を定義し、変換を共通化する
type appHandler func(w http.ResponseWriter, r *http.Request) error
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
status, msg := statusOf(err)
// slog には元のエラーをそのまま渡す("err" キーで構造化される)
slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "status", status, "err", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
}
func itemHandler(w http.ResponseWriter, r *http.Request) error {
item, err := getItem(r.PathValue("id"))
if err != nil {
return err
}
return json.NewEncoder(w).Encode(item)
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey { // 出力を安定させるため時刻は省く
return slog.Attr{}
}
return a
},
})))
mux := http.NewServeMux()
mux.Handle("GET /items/{id}", appHandler(itemHandler))
// httptest でサーバーを立てずに呼び出す
for _, path := range []string{"/items/1", "/items/2", "/items/abc"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("GET %-11s -> %d %s", path, rec.Code, rec.Body.String())
}
}
$ go run .
GET /items/1 -> 200 {"id":1,"name":"ノート"}
{"level":"ERROR","msg":"request failed","method":"GET","path":"/items/2","status":404,"err":"getItem(2): item not found"}
GET /items/2 -> 404 {"error":"not found"}
{"level":"ERROR","msg":"request failed","method":"GET","path":"/items/abc","status":400,"err":"id must be a number"}
GET /items/abc -> 400 {"error":"id must be a number"}
| エラーの種類 | ステータス | クライアントに返す内容 | ログ |
|---|---|---|---|
入力の誤り(ValidationError) |
400 | どの項目がどう誤りか | WARN か ERROR(頻度に応じて) |
見つからない(ErrNotFound) |
404 | 「not found」 | 通常は INFO 程度で十分 |
| 権限がない | 403 | 「forbidden」 | 詳細はログにだけ |
| 想定外(上記以外) | 500 | 「internal error」(詳細は返さない) | ERROR で元のエラーをそのまま記録 |
ログには包んだままのエラー(getItem(2): item not found)を残し、レスポンスには変換後の短い文言だけを返す、という分離がポイントです。
よくある間違いと実行結果(直す前と後)
1. err のシャドウイングで、エラーが外に出ない
if や内側のブロックで := を使うと「新しい err」が作られ、外側の err は nil のままです。コンパイルは通り、go vet の標準設定でも検出されないので、実行結果で確認します。
package main
import (
"errors"
"fmt"
)
func step1() (int, error) { return 0, nil }
func step2() (int, error) { return 0, errors.New("step2 failed") }
func run() error {
v, err := step1()
if v == 0 {
v, err := step2() // := で「新しい err」が作られる(外の err には入らない)
fmt.Println("inner:", v, err)
}
return err // 外の err は nil のまま
}
func main() {
fmt.Println("run() =", run())
}
$ go run .
inner: 0 step2 failed
run() = <nil>
→ 内側では v, err = step2() と = で代入する(v も外で宣言しておく)か、if v2, err := step2(); err != nil { return err } のようにその場で返す形にします。
2. 型付き nil:*MyErr を返すと err != nil が true になる
package main
import "fmt"
type MyErr struct{ Msg string }
func (e *MyErr) Error() string { return e.Msg }
// 戻り値の型を *MyErr にすると、nil を返しても error としては nil にならない
func doBad() error {
var e *MyErr // nil ポインタ
return e // (*MyErr)(nil) が error に入る → 「型あり nil」
}
func doGood() error {
var e *MyErr
if e == nil {
return nil // error 型の nil を返す
}
return e
}
func main() {
err := doBad()
fmt.Printf("doBad : err != nil -> %v (%T)\n", err != nil, err)
err = doGood()
fmt.Printf("doGood: err != nil -> %v (%T)\n", err != nil, err)
}
$ go run .
doBad : err != nil -> true (*main.MyErr)
doGood: err != nil -> false (<nil>)
→ インターフェース値は「型」と「値」の組で、型が入っていれば値が nil でも nil とは等しくなりません。 エラーを返す関数の戻り値の型は常に error にし、成功時は明示的に return nil と書きます。
3. エラーを握りつぶすと、ゼロ値のまま処理が進む
package main
import (
"fmt"
"strconv"
)
func main() {
// 直す前:エラーを捨てると、ゼロ値のまま処理が進む
qty, _ := strconv.Atoi("10") // 全角数字は変換できない
fmt.Println("直す前: qty =", qty, "→ 在庫を", qty, "個引き当てて続行")
// 直した後:エラーを返す/文脈を足して呼び出し元に任せる
if err := reserve("10"); err != nil {
fmt.Println("直した後:", err)
}
}
func reserve(s string) error {
qty, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("reserve: invalid qty %q: %w", s, err)
}
fmt.Println("引き当て:", qty)
return nil
}
$ go run .
直す前: qty = 0 → 在庫を 0 個引き当てて続行
直した後: reserve: invalid qty "10": strconv.Atoi: parsing "10": invalid syntax
→ _ で捨てるのは「失敗しても本当に問題ない」と説明できるときだけにし、それ以外は返すか記録します。errcheck(golangci-lint に含まれる)を CI に入れると、捨てている箇所を機械的に見つけられます。
4. 文字列比較は、文脈を足した時点で一致しなくなる
package main
import (
"errors"
"fmt"
"os"
)
func main() {
_, err := os.Open("/no/such/file")
err = fmt.Errorf("load config: %w", err) // 文脈を足すと文字列が変わる
fmt.Println("message:", err)
fmt.Println("string compare:", err.Error() == "open /no/such/file: no such file or directory")
fmt.Println("errors.Is :", errors.Is(err, os.ErrNotExist))
}
$ go run .
message: load config: open /no/such/file: no such file or directory
string compare: false
errors.Is : true
→ err.Error() == "..." や strings.Contains(err.Error(), "...") は、包んだ瞬間・文言を直した瞬間に壊れます。判定は errors.Is/As で、文字列は人が読むためのものと割り切ります。
5. %w に nil を渡す・Sprintf に %w を書く・errors.As にポインタ以外を渡す
var err error // nil
e := fmt.Errorf("load: %w", err) // nil を %w で包むと…
fmt.Println(e, "/ nil?", e == nil)
s := fmt.Sprintf("failed: %w", errors.New("x")) // Sprintf に %w は使えない
fmt.Println(s)
$ go vet .
./main.go:13:28: fmt.Sprintf does not support error-wrapping directive %w
$ go run .
load: %!w(<nil>) / nil? false
failed: %!w(*errors.errorString=&{x})
var target MyErr
fmt.Println(errors.As(err, target)) // ポインタを渡していない
$ go vet .
./main.go:15:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
$ go run .
panic: errors: target must be a non-nil pointer
| 間違い | 起きること | 直し方 |
|---|---|---|
fmt.Errorf("...: %w", nil) |
%!w(<nil>) を含む非 nil のエラーになる |
if err != nil の中でだけ包む |
fmt.Sprintf("%w", err) |
%!w(...) と表示。go vet が検出 |
%v にする(包むのは Errorf だけ) |
errors.As(err, target)(非ポインタ) |
実行時 panic。go vet が検出 |
errors.As(err, &target) か errors.AsType[T](err) |
errors.As(err, &v) で v が値型なのにポインタ型を返している |
一致せず false |
返している型(*MyErr)に合わせる |
テストでのエラー検証(errors.Is を使ったテーブル駆動テスト)
テストでも文字列比較ではなく errors.Is/errors.AsType で検証すると、文言を直しても壊れないテストになります。 期待するセンチネルを表の列に持たせるのが定番です。
// stock.go
package stock
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("stock: item not found")
type InsufficientError struct {
Item string
Requested int
Available int
}
func (e *InsufficientError) Error() string {
return fmt.Sprintf("stock: %s requested %d but only %d available", e.Item, e.Requested, e.Available)
}
type Store struct{ items map[string]int }
func New(items map[string]int) *Store { return &Store{items: items} }
// Take は在庫を n 個引き当てます。
func (s *Store) Take(item string, n int) error {
avail, ok := s.items[item]
if !ok {
return fmt.Errorf("take %q: %w", item, ErrNotFound)
}
if avail < n {
return &InsufficientError{Item: item, Requested: n, Available: avail}
}
s.items[item] = avail - n
return nil
}
// stock_test.go
package stock
import (
"errors"
"testing"
)
func TestTake(t *testing.T) {
tests := []struct {
name string
item string
n int
wantIs error // センチネルを期待するとき(errors.Is で判定)
wantAvail int // *InsufficientError を期待するとき(0 なら期待しない)
}{
{name: "成功", item: "pen", n: 2},
{name: "存在しない品目", item: "cup", n: 1, wantIs: ErrNotFound},
{name: "在庫不足", item: "pen", n: 99, wantAvail: 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := New(map[string]int{"pen": 5})
err := s.Take(tt.item, tt.n)
switch {
case tt.wantIs != nil: // センチネル:包まれていても errors.Is でたどれる
if !errors.Is(err, tt.wantIs) {
t.Errorf("Take() error = %v, want %v", err, tt.wantIs)
}
case tt.wantAvail != 0: // カスタム型:errors.AsType で値まで検証
ie, ok := errors.AsType[*InsufficientError](err)
if !ok {
t.Fatalf("Take() error = %v, want *InsufficientError", err)
}
if ie.Available != tt.wantAvail {
t.Errorf("Available = %d, want %d", ie.Available, tt.wantAvail)
}
default:
if err != nil {
t.Errorf("Take() unexpected error: %v", err)
}
}
})
}
}
$ go test -v ./stock
=== RUN TestTake
=== RUN TestTake/成功
=== RUN TestTake/存在しない品目
=== RUN TestTake/在庫不足
--- PASS: TestTake (0.00s)
--- PASS: TestTake/成功 (0.00s)
--- PASS: TestTake/存在しない品目 (0.00s)
--- PASS: TestTake/在庫不足 (0.00s)
PASS
ok example.com/errdemo/stock 0.002s
このテストは「%w を %v に書き間違えて連鎖が切れた」というよくある変更も検出します。Take の %w を %v に変えて実行すると次のように落ちます。
$ go test ./stock
--- FAIL: TestTake (0.00s)
--- FAIL: TestTake/存在しない品目 (0.00s)
stock_test.go:28: Take() error = take "cup": stock: item not found, want stock: item not found
FAIL
FAIL example.com/errdemo/stock 0.003s
よくある質問
Q. すべてのエラーを %w で包むべきですか?
A. 基本は包みます。文脈(関数名・ID)が付いて追跡しやすくなり、errors.Is/As も効くためです。例外は「パッケージの内部実装を外に見せたくない」境界で、そこでは %v で切るか、自分のパッケージのセンチネルに変換して返します。
Q. errors.As と errors.AsType のどちらを使えばよいですか?
A. Go 1.26 以降なら errors.AsType が簡潔で、型の誤りがコンパイル時に分かります。1.25 以前もサポートするライブラリでは errors.As を使います。動作はどちらも同じで、連鎖を深さ優先でたどります。
Q. エラーメッセージの書き方に決まりはありますか?
A. 慣例として「小文字で始め、句読点や改行で終えない」「パッケージ名: 内容 のように前置きを付ける」「包むときは 関数名(引数): %w のように文脈だけ足す」です。上位で包まれるたびに : で連結されるので、途中に「エラー:」のような語を入れないと読みやすくなります。
Q. ログはどの層で出すべきですか?
A. 「処理を打ち切る層」(HTTP ハンドラや CLI の main)で1回だけ出します。各層で記録しつつ上にも返すと、同じエラーが何度も記録されます。途中の層は %w で文脈を足して返すだけにします。
まとめ
errorはただの値。失敗しうる関数は最後の戻り値で返し、if err != nilで受け取る- 文脈を足すときは
fmt.Errorf("...: %w", err)。%vは連鎖が切れる - 種類で分岐するならセンチネル+
errors.Is、値を使うならカスタム型+errors.As/AsType(1.26〜) - 複数の失敗は
errors.Joinか複数の%wでまとめ、Is/Asは全枝を探す。errors.Unwrapはnilを返す panicは続行できない状態専用。recoverはdeferの中でだけ効き、error に変換して返す- HTTP では 1 か所でエラー→ステータスに変換し、
slogには元のエラーをそのまま記録する - シャドウイング・型付き nil・握りつぶし・文字列比較は実行結果で確認し、テストは
errors.Is/AsTypeで書く
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Goエラーハンドリング|errors.Is/As・wrapの使い分け実例集」
株式会社RJCでは、エラーを「値」として設計するGoの考え方を、コードレビューの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。