Go の sentinel エラーパターン

Go のエラーハンドリングでは、エラーの「種類」を判定したい場面が頻繁にある。データベースにレコードが見つからなかったのか、権限が足りなかったのか、タイムアウトしたのか――それぞれで後続の処理が変わるからだ。こうした判定に使われる代表的な手法が sentinel エラーパターンである。

sentinel エラーとは

sentinel エラーとは、パッケージレベルで定義された固定のエラー値のことだ。英語の sentinel は「見張り番」や「歩哨」を意味し、特定の状態を示す目印として機能する。

var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
var ErrTimeout = errors.New("timeout")

これらは var で宣言されたパッケージ変数であり、プログラム全体で共有される。呼び出し側はこの変数と返ってきたエラーを比較することで、エラーの種類を判定できる。

Go の標準ライブラリにも sentinel エラーは多数存在する。io.EOF、sql.ErrNoRows、os.ErrNotExist などがその代表例だ。

import (
	"database/sql"
	"errors"
	"io"
	"os"
)

// いずれも標準ライブラリの sentinel エラー
_ = io.EOF
_ = sql.ErrNoRows
_ = os.ErrNotExist

errors.Is による判定

sentinel エラーの判定には errors.Is を使う。単純な == 比較ではなく errors.Is を使う理由は、エラーがラップされている場合にもチェーンを辿って一致を判定できるからだ。

func findUser(id string) (*User, error) {
	row := db.QueryRow("SELECT name, email FROM users WHERE id = ?", id)

	var user User
	if err := row.Scan(&user.Name, &user.Email); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("scan failed: %w", err)
	}
	return &user, nil
}

sql.ErrNoRows をアプリケーション固有の ErrNotFound に変換している点に注目してほしい。データベース固有のエラーをそのまま上位に返すと、呼び出し側が database/sql パッケージに依存してしまう。アプリケーション層の sentinel エラーに変換することで、依存関係を断ち切れる。

ラップされたエラーと errors.Is

fmt.Errorf の %w 動詞でエラーをラップした場合、errors.Is はラップのチェーンを再帰的に辿って判定する。

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

func getUser(id string) (*User, error) {
	user, err := findUser(id)
	if err != nil {
		return nil, fmt.Errorf("getUser(%s): %w", id, err)
	}
	return user, nil
}

func handleRequest(id string) {
	user, err := getUser(id)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			// getUser → findUser → ErrNotFound と
			// チェーンを辿って一致する
			fmt.Println("user not found")
			return
		}
		fmt.Println("unexpected error:", err)
	}
	_ = user
}

handleRequest が受け取るエラーは “getUser(123): not found” のようにラップされた状態だが、errors.Is(err, ErrNotFound) は内部のエラーチェーンを辿るため正しく true を返す。もし == で比較していたら、ラップされた時点で一致しなくなってしまう。

== による比較

ラップされると一致しなくなる。err == ErrNotFound は、エラーがラップされていない場合にしか機能しない

errors.Is による比較

ラップのチェーンを辿って判定する。何層ラップされていても、チェーンのどこかに ErrNotFound があれば true を返す

sentinel エラーの命名規則

Go の慣例として、sentinel エラーの変数名は Err で始める。

// 正しい命名
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrInvalidInput = errors.New("invalid input")

// 避けるべき命名
var NotFoundError = errors.New("not found")     // Err プレフィックスなし
var ERR_NOT_FOUND = errors.New("not found")     // スネークケース

この命名規則は標準ライブラリでも一貫して守られており、io.EOF が唯一の例外的な命名として知られている。ErrNotFound のように Err + 状態名とするのが最も読みやすい形だ。

sentinel エラーの設計指針

sentinel エラーをどの粒度で定義するかは設計上の判断が求められる部分だ。多すぎるとパッケージの API が膨張し、少なすぎると呼び出し側がエラーの種類を判定できなくなる。

パッケージの公開 API として意識する

sentinel エラーはエクスポートされた変数であり、パッケージの公開 API の一部になる。一度公開すると後方互換性の制約を受けるため、本当に呼び出し側が判定する必要があるものだけを公開すべきだ。

内部エラーは非公開にする

パッケージ内部でしか使わないエラーは小文字で定義し、外部に公開しない。errRetryable のように小文字で始めることで、パッケージ外からはアクセスできなくなる。

文脈情報は sentinel に含めない

sentinel エラーのメッセージは短く汎用的にする。“user 123 not found” のような具体的な値を含めると、sentinel としての比較ができなくなる。具体的な文脈は %w でラップする際に付与する。

3 つ目の点は特に重要だ。以下のように書くと sentinel エラーとして機能しなくなる。

// 毎回新しい error が生成されるため sentinel として使えない
func notFound(id string) error {
	return errors.New("user " + id + " not found")
}

// 正しいアプローチ
var ErrNotFound = errors.New("not found")

func findUser(id string) (*User, error) {
	// ...
	return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}

sentinel エラー自体は固定の値として保ち、文脈情報はラップ時に付与する。こうすることで errors.Is による判定が機能し続ける。

sentinel エラーとカスタムエラー型の使い分け

エラーの「種類」だけを判定すればよいなら sentinel エラーで十分だ。一方、エラーに付随する情報(HTTP ステータスコード、フィールド名、リトライ可否など)を持たせたいなら、カスタムエラー型を使う方が適している。

// sentinel エラー: 種類の判定だけでよい場合
var ErrNotFound = errors.New("not found")

if errors.Is(err, ErrNotFound) {
	// 見つからなかった
}

// カスタムエラー型: 付随情報が必要な場合
type ValidationError struct {
	Field   string
	Message string
}

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

var ve *ValidationError
if errors.As(err, &ve) {
	// ve.Field, ve.Message にアクセスできる
}

判定に errors.Is を使うか errors.As を使うかが、sentinel エラーとカスタムエラー型を選ぶ基準になる。errors.Is値の一致を、errors.As は型の一致を調べる関数だ。

特定のエラー変数と同一かどうかを、ラップチェーンを辿って判定する。

両方を組み合わせるケースもある。たとえば、sentinel エラーをカスタムエラー型の中にラップして保持するパターンだ。

type AppError struct {
	Code    int
	Message string
	Err     error
}

func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }

// sentinel を内包したカスタムエラー
func NewNotFoundError(msg string) *AppError {
	return &AppError{
		Code:    404,
		Message: msg,
		Err:     ErrNotFound,
	}
}

こうしておけば、errors.Is(err, ErrNotFound) でも errors.As(err, &appErr) でもエラーを判定でき、柔軟な設計が可能になる。プロジェクトの規模や要件に応じて、sentinel エラーだけで済ませるか、カスタムエラー型と組み合わせるかを判断するとよいだろう。