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.ErrNotExisterrors.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 は、エラーがラップされていない場合にしか機能しない
ラップのチェーンを辿って判定する。何層ラップされていても、チェーンのどこかに 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 が膨張し、少なすぎると呼び出し側がエラーの種類を判定できなくなる。
sentinel エラーはエクスポートされた変数であり、パッケージの公開 API の一部になる。一度公開すると後方互換性の制約を受けるため、本当に呼び出し側が判定する必要があるものだけを公開すべきだ。
パッケージ内部でしか使わないエラーは小文字で定義し、外部に公開しない。errRetryable のように小文字で始めることで、パッケージ外からはアクセスできなくなる。
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 エラーだけで済ませるか、カスタムエラー型と組み合わせるかを判断するとよいだろう。