📚 背景知識(読んでから問題へ)
Day 016・017ではcontext.Err()がcontext.Canceledやcontext.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自身、またはerrをUnwrapし続けた先のどこかにtargetと同じ値のエラーが存在するかを判定する(センチネルエラー用)errors.As(err, &target)—err自身、またはUnwrapチェーンのどこかにtargetと同じ型のエラーが存在すれば、その値をtargetに代入する(カスタムエラー型用)
重要なのは、errors.Isとerrors.Asはどちらも「チェーン全体」を自動的に辿ってくれるという点です。呼び出し元は「このエラーは何段階ラップされているか」を一切気にする必要がありません。これはGoの「明示的だが冗長すぎない」というバランス感覚をよく表した設計です——エラー処理そのものはif err != nilで明示的に行いつつ、ラップされたエラーの判定はライブラリが面倒を見てくれます。
使い分けの基準はシンプルです。「同じ値かどうか」を知りたい場合はerrors.Is(例: io.EOF・sql.ErrNoRowsのようなセンチネルエラー)、「特定の型で、その型が持つ追加情報(フィールド)を取り出したい」場合はerrors.Asを使います。
📝 問題
以下の要件を満たす、go run main.goでそのまま実行できる1つのGoプログラムを実装してください。ユーザー管理システムの「リポジトリ層 → サービス層」という2層構造を想定します。
var ErrNotFound = errors.New("record not found")というセンチネルエラーを定義してくださいtype ValidationError struct { Field string; Msg string }を定義し、Error() stringメソッドを実装してerrorインターフェースを満たすようにしてください(メッセージは"validation failed on <Field>: <Msg>"という形式にすること)UserRepository型(map[int]*Userを内部に持つ)を実装し、FindByID(id int) (*User, error)メソッドで、存在しないIDが渡された場合にfmt.Errorfの%wを使ってErrNotFoundをラップしたエラーを返してください(文脈情報としてidとメソッド名を含めること)validateName(name string) error関数を実装し、nameが空文字列の場合は*ValidationErrorを返してくださいUserService型を実装し、以下2つのメソッドで下位層のエラーを%wでラップして返してくださいGetUser(id int) (*User, error)—UserRepository.FindByIDのエラーをラップするRegisterUser(id int, name string) error—validateNameのエラーをラップする
main関数で以下の3ケースを実行し、それぞれ結果を出力してください- 存在するユーザーIDで
GetUserを呼び、成功結果を表示する - 存在しないユーザーIDで
GetUserを呼び、エラー全文を表示した上でerrors.Is(err, ErrNotFound)で判定し、判定結果に応じたメッセージを出す - 空文字列の名前で
RegisterUserを呼び、エラー全文を表示した上でerrors.Asで*ValidationErrorを取り出し、そのField・Msgを個別に表示する
- 存在するユーザーIDで
- 最後に、
RegisterUserで得られたエラーに対してerrors.Unwrapを使い、チェーンを1段階ずつ辿って全ての段階のエラーメッセージを表示してください(nilになるまでループする)
🔍 ヒント(段階的開示)
ヒント1 — 方向性
errors.Isとerrors.Asは、内部でerrors.Unwrapを繰り返し呼びながらチェーンを辿っています。つまりfmt.Errorf("...: %w", err)で何段階ラップしても、判定する側は「元のエラーが何であったか」だけを気にすればよく、途中の文脈情報(ラップ回数やメッセージ)を気にする必要はありません。まずは「ラップする側(%wを書く場所)」と「判定する側(errors.Is/errors.Asを書く場所)」がレイヤーとして分離されていることを意識してください。
ヒント2 — アプローチ
errors.Is(err, ErrNotFound)の第2引数は「比較対象の値」です。ErrNotFoundはerrors.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行しかないのは、*ValidationErrorがUnwrap() errorメソッドを実装していない(それ以上ラップしていない、生成元のエラーである)ためです。
🪜 Step-by-Step 解説
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は「型」であり、Field・Msgという追加情報を持つため、値の比較ではなく型としての判定(errors.As)が必要になります。「値で十分な場合はセンチネルエラー、追加情報が必要な場合はカスタム型」という使い分けがここに現れています。
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」という連鎖が保持されています。
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段階になります。各層は「自分がどこで失敗したか」という情報だけを追加すればよく、下位層のエラーの中身を意識する必要はありません。
if errors.Is(err, ErrNotFound) {
fmt.Println("→ ErrNotFound として判定できた")
}
errors.Is(err, ErrNotFound)は、err自身がErrNotFoundと一致するか、一致しなければerrors.Unwrap(err)した結果と比較、それでも一致しなければさらにUnwrap……という具合に、チェーンの末端まで自動的に辿ります。3段階ラップされていても、呼び出し側のコードは1行で済みます。
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Field=%s Msg=%s\n", valErr.Field, valErr.Msg)
}
errors.Asは「チェーンの中に指定した型のエラーがあれば、そのエラー値そのもの(FieldやMsgを含む)を取り出す」という点でerrors.Isと役割が異なります。errors.Isは「はい/いいえ」の判定しかできませんが、errors.Asは判定と同時に値の取り出しまで行うため、&targetという代入先ポインタを渡す形になっています。
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()メソッドを定義する必要はありません。逆に追加情報が必要になった瞬間にだけ、カスタム型という一段重い道具を選択する、という判断基準が明確になっています。🌐 他言語との比較
| 観点 | Go | Java | Python | Node.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/catch、Promiseでは.catch/awaitの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 カスタムエラー型」をどう使い分けて設計するかを、より実践的なシナリオで検討します