Day 018 — エラーラッピング(errors.Is/As/Unwrap)

2026-08-15 🔵 中級者 / Phase 2 実装 エラーラッピング(errors.Is/As/Unwrap)

📚 背景知識(読んでから問題へ)

Day 016・017ではcontext.Err()context.Canceledcontext.DeadlineExceededという「特定のエラー値」を返すことを見てきました。実務のアプリケーションでは、下位レイヤー(リポジトリ層)で発生したエラーに「どこで・何をしていたときに起きたか」という文脈情報を追加しながら、上位レイヤー(サービス層・ハンドラ層)へ伝搬させていくことがよくあります。しかし単純にfmt.Errorf("GetUser: %v", err)のように%vで文字列化してしまうと、元のエラーが何であったかという情報は文字列の中に埋もれてしまい、呼び出し元は「文字列の中身をパースする」以外の方法で元のエラーを判定できなくなります。

Go 1.13で導入された%w動詞とerrorsパッケージの3関数は、この問題を「エラーのチェーン(連鎖)」として構造的に解決します。

  • fmt.Errorf("...: %w", err)errラップした新しいエラーを作る。文字列としては文脈が追加されるが、元のerrへの参照は内部に保持され続ける
  • errors.Unwrap(err) — ラップされたエラーから1段階だけ元のエラーを取り出す。ラップしていなければnilを返す
  • errors.Is(err, target)err自身、またはerrUnwrapし続けた先のどこかにtarget同じ値のエラーが存在するかを判定する(センチネルエラー用)
  • errors.As(err, &target)err自身、またはUnwrapチェーンのどこかにtarget同じ型のエラーが存在すれば、その値をtargetに代入する(カスタムエラー型用)

重要なのは、errors.Iserrors.Asはどちらも「チェーン全体」を自動的に辿ってくれるという点です。呼び出し元は「このエラーは何段階ラップされているか」を一切気にする必要がありません。これはGoの「明示的だが冗長すぎない」というバランス感覚をよく表した設計です——エラー処理そのものはif err != nilで明示的に行いつつ、ラップされたエラーの判定はライブラリが面倒を見てくれます。

使い分けの基準はシンプルです。「同じ値かどうか」を知りたい場合はerrors.Is(例: io.EOFsql.ErrNoRowsのようなセンチネルエラー)、「特定の型で、その型が持つ追加情報(フィールド)を取り出したい」場合はerrors.Asを使います。

📝 問題

以下の要件を満たす、go run main.goでそのまま実行できる1つのGoプログラムを実装してください。ユーザー管理システムの「リポジトリ層 → サービス層」という2層構造を想定します。

  1. var ErrNotFound = errors.New("record not found")というセンチネルエラーを定義してください
  2. type ValidationError struct { Field string; Msg string }を定義し、Error() stringメソッドを実装してerrorインターフェースを満たすようにしてください(メッセージは"validation failed on <Field>: <Msg>"という形式にすること)
  3. UserRepository型(map[int]*Userを内部に持つ)を実装し、FindByID(id int) (*User, error)メソッドで、存在しないIDが渡された場合にfmt.Errorf%wを使ってErrNotFoundをラップしたエラーを返してください(文脈情報としてidとメソッド名を含めること)
  4. validateName(name string) error関数を実装し、nameが空文字列の場合は*ValidationErrorを返してください
  5. UserService型を実装し、以下2つのメソッドで下位層のエラーを%wでラップして返してください
    • GetUser(id int) (*User, error)UserRepository.FindByIDのエラーをラップする
    • RegisterUser(id int, name string) errorvalidateNameのエラーをラップする
  6. main関数で以下の3ケースを実行し、それぞれ結果を出力してください
    • 存在するユーザーIDでGetUserを呼び、成功結果を表示する
    • 存在しないユーザーIDでGetUserを呼び、エラー全文を表示した上でerrors.Is(err, ErrNotFound)で判定し、判定結果に応じたメッセージを出す
    • 空文字列の名前でRegisterUserを呼び、エラー全文を表示した上でerrors.As*ValidationErrorを取り出し、そのFieldMsgを個別に表示する
  7. 最後に、RegisterUserで得られたエラーに対してerrors.Unwrapを使い、チェーンを1段階ずつ辿って全ての段階のエラーメッセージを表示してください(nilになるまでループする)

🔍 ヒント(段階的開示)

ヒント1 — 方向性

errors.Iserrors.Asは、内部でerrors.Unwrapを繰り返し呼びながらチェーンを辿っています。つまりfmt.Errorf("...: %w", err)で何段階ラップしても、判定する側は「元のエラーが何であったか」だけを気にすればよく、途中の文脈情報(ラップ回数やメッセージ)を気にする必要はありません。まずは「ラップする側(%wを書く場所)」と「判定する側(errors.Is/errors.Asを書く場所)」がレイヤーとして分離されていることを意識してください。

ヒント2 — アプローチ
  • errors.Is(err, ErrNotFound)の第2引数は「比較対象の値」です。ErrNotFounderrors.Newで作られた1つの値なので、==比較に近い意味になります
  • errors.As(err, &target)の第2引数はポインタです。var valErr *ValidationErrorと宣言してからerrors.As(err, &valErr)のように渡してください(型を間違えるとerrors.Asはpanicします)
  • *ValidationError型にError() stringメソッドを実装すれば、&ValidationError{...}をそのままerror型の値として返すことができます(Day 011で学んだ「暗黙的インターフェース実装」の応用です)
  • errors.Unwrap(err)はラップされていない(Unwrap() errorを実装していない)エラーに対してnilを返します。これをfor e := err; e != nil; e = errors.Unwrap(e)のようなループの終了条件に使えます
ヒント3 — コード骨格
package main

import (
	"errors"
	"fmt"
)

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

type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	// TODO: "validation failed on <Field>: <Msg>" を返す
}

type User struct {
	ID   int
	Name string
}

type UserRepository struct {
	users map[int]*User
}

func NewUserRepository() *UserRepository {
	return &UserRepository{
		users: map[int]*User{
			1: {ID: 1, Name: "Alice"},
			2: {ID: 2, Name: "Bob"},
		},
	}
}

func (r *UserRepository) FindByID(id int) (*User, error) {
	// TODO: 存在しなければ fmt.Errorf("UserRepository.FindByID(%d): %w", id, ErrNotFound) を返す
}

func validateName(name string) error {
	// TODO: 空文字列なら &ValidationError{Field: "name", Msg: "must not be empty"} を返す
}

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(id int) (*User, error) {
	// TODO: repo.FindByID を呼び、エラーなら %w でラップする
}

func (s *UserService) RegisterUser(id int, name string) error {
	// TODO: validateName を呼び、エラーなら %w でラップする
}

func main() {
	repo := NewUserRepository()
	svc := &UserService{repo: repo}

	// ケース1: 成功パターン

	// ケース2: errors.Is で ErrNotFound を判定

	// ケース3: errors.As で *ValidationError を取り出す

	// ケース4: errors.Unwrap でチェーンを1段階ずつ辿る
}

模範解答

package main

import (
	"errors"
	"fmt"
)

// ErrNotFound はリポジトリ層で「該当レコードなし」を表すセンチネルエラー。
// 呼び出し元は errors.Is で「何段階ラップされていても」この値かどうかを判定できる。
var ErrNotFound = errors.New("record not found")

// ValidationError はバリデーション失敗を表すカスタムエラー型。
// Field・Msg という追加情報を持つため、センチネルエラーではなく型で判定する。
type ValidationError struct {
	Field string
	Msg   string
}

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

type User struct {
	ID   int
	Name string
}

type UserRepository struct {
	users map[int]*User
}

func NewUserRepository() *UserRepository {
	return &UserRepository{
		users: map[int]*User{
			1: {ID: 1, Name: "Alice"},
			2: {ID: 2, Name: "Bob"},
		},
	}
}

// FindByID は存在しないIDの場合、ErrNotFoundを%wでラップして返す。
// エラーメッセージには「どのメソッドの、どのIDで」起きたかという文脈を追加する。
func (r *UserRepository) FindByID(id int) (*User, error) {
	u, ok := r.users[id]
	if !ok {
		return nil, fmt.Errorf("UserRepository.FindByID(%d): %w", id, ErrNotFound)
	}
	return u, nil
}

// validateName は空文字列の場合に *ValidationError を返す。
func validateName(name string) error {
	if name == "" {
		return &ValidationError{Field: "name", Msg: "must not be empty"}
	}
	if len(name) > 20 {
		return &ValidationError{Field: "name", Msg: "must be 20 characters or fewer"}
	}
	return nil
}

type UserService struct {
	repo *UserRepository
}

func NewUserService(repo *UserRepository) *UserService {
	return &UserService{repo: repo}
}

// GetUser はリポジトリ層のエラーに「サービス層のどのメソッドで起きたか」を追加してラップする。
func (s *UserService) GetUser(id int) (*User, error) {
	u, err := s.repo.FindByID(id)
	if err != nil {
		return nil, fmt.Errorf("UserService.GetUser: %w", err)
	}
	return u, nil
}

// RegisterUser も同様に、バリデーションエラーを文脈付きでラップする。
func (s *UserService) RegisterUser(id int, name string) error {
	if err := validateName(name); err != nil {
		return fmt.Errorf("UserService.RegisterUser: %w", err)
	}
	// 実際にはここでDB保存処理などが続く
	return nil
}

func main() {
	repo := NewUserRepository()
	svc := NewUserService(repo)

	// ケース1: 存在するユーザー取得(成功パターン)
	if u, err := svc.GetUser(1); err == nil {
		fmt.Println("見つかった:", u.Name)
	}

	// ケース2: 存在しないユーザー取得 → errors.Is で ErrNotFound かを判定
	_, err := svc.GetUser(99)
	if err != nil {
		fmt.Println("エラー全文:", err)
		if errors.Is(err, ErrNotFound) {
			fmt.Println("→ ErrNotFound として判定できた(3段階ラップされていても検出できる)")
		}
	}

	// ケース3: バリデーションエラー → errors.As で *ValidationError を取り出す
	err = svc.RegisterUser(3, "")
	if err != nil {
		fmt.Println("エラー全文:", err)
		var valErr *ValidationError
		if errors.As(err, &valErr) {
			fmt.Printf("→ ValidationError として判定できた: Field=%s Msg=%s\n", valErr.Field, valErr.Msg)
		}
	}

	// ケース4: Unwrap チェーンを手動でたどる
	fmt.Println("--- Unwrap チェーン ---")
	for e := err; e != nil; e = errors.Unwrap(e) {
		fmt.Printf("  %v\n", e)
	}
}
▶ 実行結果を見る(go run main.go で検証済み)
見つかった: Alice
エラー全文: UserService.GetUser: UserRepository.FindByID(99): record not found
→ ErrNotFound として判定できた(3段階ラップされていても検出できる)
エラー全文: UserService.RegisterUser: validation failed on name: must not be empty
→ ValidationError として判定できた: Field=name Msg=must not be empty
--- Unwrap チェーン ---
  UserService.RegisterUser: validation failed on name: must not be empty
  validation failed on name: must not be empty

※ ケース2のエラーは「UserService.GetUser(1段目)→ UserRepository.FindByID(99)(2段目)→ ErrNotFound(3段目)」という3段階のチェーンになっていますが、errors.Isは途中の段数を意識せずに末端のErrNotFoundを見つけ出せています。ケース4のUnwrapチェーンが2行しかないのは、*ValidationErrorUnwrap() errorメソッドを実装していない(それ以上ラップしていない、生成元のエラーである)ためです。

🪜 Step-by-Step 解説

1
センチネルエラーとカスタムエラー型を定義する
var ErrNotFound = errors.New("record not found")

type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}
ErrNotFoundはパッケージレベルの変数として1つだけ存在する「値」です。errors.Isはこの値と「同じ値」かどうかを比較します。一方ValidationErrorは「型」であり、FieldMsgという追加情報を持つため、値の比較ではなく型としての判定(errors.As)が必要になります。「値で十分な場合はセンチネルエラー、追加情報が必要な場合はカスタム型」という使い分けがここに現れています。
2
リポジトリ層でセンチネルエラーを%wでラップする
func (r *UserRepository) FindByID(id int) (*User, error) {
	u, ok := r.users[id]
	if !ok {
		return nil, fmt.Errorf("UserRepository.FindByID(%d): %w", id, ErrNotFound)
	}
	return u, nil
}
%w%v%sと違い、「このエラーはErrNotFoundをラップしている」という関係性をGoランタイムに記録します。具体的には、fmt.Errorfが返すエラー値の内部にUnwrap() errorメソッドが自動生成され、それがErrNotFoundを返すようになります。文字列としては"UserRepository.FindByID(99): record not found"という1行の情報ですが、構造としては「今作った新しいエラー → ErrNotFound」という連鎖が保持されています。
3
サービス層でさらにもう一段ラップする
func (s *UserService) GetUser(id int) (*User, error) {
	u, err := s.repo.FindByID(id)
	if err != nil {
		return nil, fmt.Errorf("UserService.GetUser: %w", err)
	}
	return u, nil
}
サービス層は、リポジトリ層から受け取ったerr(それ自体がすでにErrNotFoundをラップしている)を、さらに%wでラップします。これによりチェーンは「UserService.GetUserのエラー → UserRepository.FindByIDのエラー → ErrNotFound」という3段階になります。各層は「自分がどこで失敗したか」という情報だけを追加すればよく、下位層のエラーの中身を意識する必要はありません。
4
errors.Is でセンチネルエラーを判定する
if errors.Is(err, ErrNotFound) {
	fmt.Println("→ ErrNotFound として判定できた")
}
errors.Is(err, ErrNotFound)は、err自身がErrNotFoundと一致するか、一致しなければerrors.Unwrap(err)した結果と比較、それでも一致しなければさらにUnwrap……という具合に、チェーンの末端まで自動的に辿ります。3段階ラップされていても、呼び出し側のコードは1行で済みます。
5
errors.As でカスタムエラー型を取り出す
var valErr *ValidationError
if errors.As(err, &valErr) {
	fmt.Printf("Field=%s Msg=%s\n", valErr.Field, valErr.Msg)
}
errors.Asは「チェーンの中に指定した型のエラーがあれば、そのエラー値そのもの(FieldMsgを含む)を取り出す」という点でerrors.Isと役割が異なります。errors.Isは「はい/いいえ」の判定しかできませんが、errors.Asは判定と同時に値の取り出しまで行うため、&targetという代入先ポインタを渡す形になっています。
6
errors.Unwrap でチェーンを手動で辿る
for e := err; e != nil; e = errors.Unwrap(e) {
	fmt.Printf("  %v\n", e)
}
errors.Is/errors.Asが内部でやっていることを、あえて手動で再現しています。errors.Unwrapはラップされていないエラー(Unwrap() errorを実装していない、あるいはnil)に到達するとnilを返すため、このループは自然に終了します。実務でこのループそのものを書く機会は少ないですが、errors.Is/errors.Asが「何をしているか」を体感するのに役立ちます。

💡 設計思想・なぜこう書くのか

📌
「ただの値」という性質を保ったままのチェーン化: %wによるエラーラッピングは、Goの「エラーは特別な例外機構ではなく、ただの戻り値である」という設計思想を保ったまま、チェーンという概念を後付けした点が特徴的です。他言語の例外機構(Java/Pythonのthrow/raise)は、言語のランタイムレベルで「スタックを巻き戻しながら伝搬する」特別な制御フローを持ちますが、Goのエラーはあくまでerrorインターフェースを満たす普通の値であり、returnで明示的に返され、呼び出し元はif err != nilで明示的にチェックします。%wはこの「普通の値」という性質を壊さずに、「元のエラーへの参照を1つ内部に持つ」という最小限の仕組みだけを追加しています。
📌
用途に応じて最小限の道具を選ぶ: センチネルエラー(errors.Is)とカスタムエラー型(errors.As)という2つの判定方法が並存している設計も、Goの「用途に応じて最小限の道具を選ぶ」姿勢の表れです。追加情報が不要ならただのerrors.Newの値で十分であり、わざわざ構造体とError()メソッドを定義する必要はありません。逆に追加情報が必要になった瞬間にだけ、カスタム型という一段重い道具を選択する、という判断基準が明確になっています。

🌐 他言語との比較

観点GoJavaPythonNode.js
エラーの連鎖の作り方fmt.Errorf("...: %w", err)で明示的にラップし、戻り値として返すthrow new ServiceException("...", cause)のように、例外コンストラクタのcause引数に元の例外を渡すraise ServiceError("...") from err__cause__に自動設定される)throw new Error("...", { cause: err })(Node.js 16.9+/ES2022のcauseオプション)
伝搬のさせ方呼び出し元がif err != nilで毎回明示的にチェックし、returnで伝える例外を投げると自動的にスタックを巻き戻して伝搬する(try/catchしない限り)同上(try/exceptしない限り自動伝搬)同上(try/catchPromiseでは.catchawaitのtry-catch)
特定の原因かどうかの判定errors.Is(err, target) — チェーン全体を自動で辿るcatch (ServiceException e) { if (e.getCause() instanceof IOException) ... }のように手動でgetCause()を辿ることが多いexcept ServiceError as e: if isinstance(e.__cause__, IOError)のように手動で__cause__を辿ることが多いerr.causeを手動で辿る必要がある(言語標準にerrors.Is相当の関数はない)
追加情報の取り出しerrors.As(err, &target) — チェーン全体から型一致するものを自動で取り出す例外クラスそのものが情報を持つため、catchする型を指定すれば自動的に得られる同上(exceptする型を指定)同上(instanceofでチェック)

Goが際立つのは、例外機構という特別な言語機能を使わずにerrors.Is/errors.Asという2つの関数呼び出しだけで「チェーン全体を自動的に辿る」という、他言語では例外のcauseチェーンを手動で辿ることが多い作業を実現している点です。エラー自体はreturnで明示的に伝播する「ただの値」でありながら、判定だけはチェーンを意識せずに書ける、というバランスがGo特有の設計です。

🏆 実務での使いどころ

  • DBエラーの判定: database/sqlパッケージのsql.ErrNoRowsのようなセンチネルエラーを、リポジトリ層でラップしてサービス層に伝え、サービス層がerrors.Is(err, sql.ErrNoRows)で「レコードが存在しない」ケースだけを404エラーに変換する、といったHTTPハンドラの実装で頻出します
  • リトライ可能性の判定: ネットワークエラー・タイムアウトエラーなど「リトライすべきエラー」をカスタム型(例: *RetryableError)として定義し、errors.Asでリトライロジックに渡すかどうかを判定する
  • バリデーションエラーの構造化レスポンス: 今回のValidationErrorのように、APIのバリデーションエラーを「どのフィールドで何が悪いか」を持つ型として定義し、errors.Asでハンドラ層が取り出して、クライアントに{"field": "name", "message": "..."}のようなJSONレスポンスを返す
  • ログの構造化: errors.Unwrapでチェーンを辿りながら各段階のエラーメッセージをログのfieldsに分解して出力し、「どのレイヤーで最初に問題が起きたか」をログ基盤(Datadog/CloudWatch等)で追いやすくする

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
fmt.Errorf%vを使ってエラーをラップしてしまう(%wを忘れる)見た目の出力は%vでも%wでも同じなので気づきにくい%vは文字列化するだけでチェーンを作らない。errors.Is/errors.Asで判定したいなら必ず%wを使う必要がある
errors.Is==比較を同じだと思い、err == ErrNotFoundと書いてしまうラップされていない単純なケースでは両者が同じ結果になるためエラーが1段階でもラップされていると==比較は常にfalseになる(ラップされた新しい値は元の値とは別物のため)。ラップされている可能性がある限りerrors.Isを使うべき
errors.Asの第2引数にポインタではなく値を渡してしまう(errors.As(err, valErr)errors.Isの第2引数(値)との類推で書いてしまうerrors.Asは代入先に値を書き込む必要があるため、必ず&target(ポインタのポインタ、この例では**ValidationError)を渡す。型が違うと実行時にpanicする
センチネルエラーで済む場面でもカスタムエラー型を作ってしまう(過剰設計)「ちゃんとした型を作った方が丁寧」という感覚追加情報(フィールド)が不要ならerrors.Newの値1つで十分。YAGNIの原則どおり、必要になってから型を導入すればよい

🚀 次のステップ

  • 発展: 今回のValidationErrorを「複数のフィールドエラーをまとめて返す」type ValidationErrors []*ValidationErrorに拡張し、Go 1.20で追加されたerrors.Joinを使って複数のエラーを1つにまとめる方法を調べてみましょう
  • 次回予告: Day 019 — カスタムエラー型設計(設計・アーキテクチャ)。今回学んだerrors.Is/errors.Asを踏まえ、「センチネルエラー vs カスタムエラー型」をどう使い分けて設計するかを、より実践的なシナリオで検討します

🎯 自己評価

自分の回答

気づき・メモ