Day 019 — カスタムエラー型設計(センチネル vs カスタム型の使い分け)

2026-08-16 🔵 中級者 / Phase 2 設計・アーキテクチャ カスタムエラー型設計(センチネル vs カスタム型の使い分け)

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

Day 018ではerrors.Is(センチネルエラーの判定)とerrors.As(カスタムエラー型の判定)という「使い分けの道具」を学びました。今日はその一歩先、「そもそも、あるエラーをセンチネルエラーとして定義すべきか、カスタムエラー型として定義すべきか」という設計判断そのものを扱います。

この判断は実務で頻繁に発生します。新しいエラーケースを追加するたびに、次のどちらかを選ぶ必要があります。

  • センチネルエラーvar ErrXxx = errors.New("...")): パッケージレベルの単一の値。「このエラーかどうか」の二値判定にしか使えないが、シンプルで軽量
  • カスタムエラー型type XxxError struct { ... }): 独自のフィールドを持つ構造体。「どのフィールドが」「どんな理由で」失敗したかという追加情報を持ち運べるが、定義・利用のコストが高い

判断基準は主に3つです。

  1. 呼び出し元が追加情報を必要とするか — 「失敗した」という事実だけで十分ならセンチネル、「どのフィールドが」「いくつ超過したか」などの情報が必要ならカスタム型
  2. エラーの種類が今後増減する可能性があるか — 頻繁に種類が増えるならカスタム型にCodeのようなフィールドを持たせて拡張しやすくする、逼迫していないなら都度センチネルを足す方が単純
  3. 公開APIの後方互換性コスト — センチネルエラーはvarの追加だけで済むが、カスタム型は構造体のフィールド変更が破壊的変更になりやすい(後方互換のためにフィールドの追加はできてもリネーム・削除はできない)

Goの標準ライブラリ自身もこの2つを併用しています。たとえばioパッケージはio.EOFという値だけのセンチネルエラーを使う一方、strconvパッケージは*strconv.NumErrorという「どの関数の・どの入力値で・どんなエラーが起きたか」を持つカスタム型を使っています。この違いは偶然ではなく、「io.EOFはただ『終端に達した』という事実だけで十分」「strconvのエラーは元の入力文字列を見せないとデバッグしづらい」という要件の違いを反映した設計判断です。

「とりあえずカスタム型にしておけば安全」という判断は過剰設計(YAGNI違反)になりがちです。逆に「とりあえずセンチネルで済ませる」を続けると、後から「このエラーの原因になった値が知りたい」という要求が来たときに、公開APIを壊さずに情報を追加する方法がなくなり詰みます。この見極めが今日のテーマです。

📝 問題

あなたは決済代行システムのSDK(他のチームが利用する内部ライブラリ)のpaymentパッケージを設計しています。以下の4つのエラーケースについて、それぞれ「センチネルエラー」と「カスタムエラー型」のどちらで設計すべきかを判断し、理由を示した上で、実際に動くpaymentパッケージと、それを利用するmainパッケージのコードを実装してください。

エラーケース

  1. 残高不足: 決済しようとした金額が口座残高を超えている。呼び出し元は「不足額」と「現在の残高」をログ・ユーザー表示のために取得したい
  2. 決済処理の一時停止: メンテナンス等でシステム全体が決済を受け付けていない状態。呼び出し元は「このエラーかどうか」だけを判定し、リトライキューに積む
  3. 不正なカード番号形式: 呼び出し元は「どのフィールド(CardNumber/ExpiryDate/CVV)が」「どんな理由で」不正だったかを取得し、フォームのエラー表示に使いたい
  4. 限度額超過: 1回の決済における上限金額(MaxAmount)を超えた。呼び出し元は「超過額」ではなく「限度額そのもの」をエラーメッセージに含めて表示したいが、リトライ判定には使わない

実装要件

  1. 上記4ケースそれぞれについて、センチネルエラー/カスタムエラー型のどちらを採用するかを設計判断として(コード内のコメントで)明記し、各ケースごとに1〜2文で理由を書くこと
  2. paymentパッケージにCharge(accountBalance int, amount int, card Card) errorという関数を実装し、上記4ケースを判定順に検査して、該当するエラーを返すこと(Card構造体はCardNumber string, ExpiryDate string, CVV stringを持つ)
  3. カード番号は「16桁の数字であること」、有効期限は"MM/YY"形式であること、CVVは「3桁の数字であること」を検証すること
  4. main関数で以下をすべて実演すること
    • 残高不足ケースを発生させ、errors.Asで追加情報(不足額・残高)を取り出して表示する
    • 決済停止ケースを発生させ、errors.Isでリトライキューに積むかどうかを判定する
    • 不正なカード番号ケースを発生させ、errors.Asでフィールド名と理由を取り出す
    • 限度額超過ケースを発生させ、エラーメッセージに限度額が含まれることを確認する
  5. すべてのエラーはChargeの呼び出し元まで正しく伝わり、判定できる状態で返ること(ラップが必要な場合は%wを使うこと)

🔍 ヒント(段階的開示)

ヒント1 — 方向性

4つのケースを「呼び出し元が追加情報を必要とするか」という1つの軸だけでまず仮に分類してみてください。「情報が要る=カスタム型」「情報が要らない=センチネル」が基本線ですが、1つだけこの基本線に反する(ように見えて実は反していない)ケースが混ざっています。ケース4を注意深く読み、「エラーの中に情報を含める」ことと「その情報を動的な値として持ち運ぶ」ことは必ずしもイコールではない、という点を考えてみてください。

ヒント2 — アプローチ
  • ケース1(残高不足)とケース3(不正なカード番号)は、呼び出し元がケースごとに異なる動的な値(不足額・残高/フィールド名・理由)を取り出す必要があるため、フィールドを持つカスタムエラー型が適切です
  • ケース2(決済停止)は、値を伴わない「はい/いいえ」の状態フラグなので、var ErrPaymentSuspended = errors.New(...)という1つのセンチネルエラーで十分です
  • ケース4(限度額超過)が引っかけです。「メッセージに限度額を含めたい」という要件だけなら、MaxAmountというその時点で固定の定数fmt.Errorfでメッセージに焼き込んだセンチネル的なエラーで足ります。呼び出し元は「動的に変わる値」を取り出す必要がなく、リトライ判定にも使わないため、フィールド付きのカスタム型を作るのは過剰設計です
  • カスタム型を作る場合は、errors.Asで判定できるようError() stringメソッドを実装し、エラーの型は基本的にポインタ型(*InsufficientBalanceError)で統一してください
ヒント3 — コード骨格
package payment

import (
	"errors"
	"fmt"
	"regexp"
)

// ケース2: 状態フラグのみ → センチネルエラー
var ErrPaymentSuspended = errors.New("payment system is currently suspended")

const MaxAmount = 100000 // ケース4: 固定の上限値。呼び出し元も参照できるよう公開定数にする

// ケース1: 動的な追加情報(不足額・残高)が必要 → カスタムエラー型
type InsufficientBalanceError struct {
	Amount  int
	Balance int
}

func (e *InsufficientBalanceError) Error() string {
	// TODO: 不足額 = Amount - Balance を含むメッセージを返す
}

// ケース3: 動的な追加情報(フィールド名・理由)が必要 → カスタムエラー型
type InvalidCardError struct {
	Field  string
	Reason string
}

func (e *InvalidCardError) Error() string {
	// TODO
}

type Card struct {
	CardNumber string
	ExpiryDate string
	CVV        string
}

var (
	cardNumberRe = regexp.MustCompile(`^\d{16}$`)
	expiryDateRe = regexp.MustCompile(`^(0[1-9]|1[0-2])/\d{2}$`)
	cvvRe        = regexp.MustCompile(`^\d{3}$`)
)

func Charge(accountBalance int, amount int, card Card) error {
	// TODO: 判定順序 → 決済停止 → カード検証(番号・期限・CVV) → 限度額 → 残高
	// TODO: 各ケースで対応するエラーを返す(センチネルはそのまま or %wでラップ、カスタム型は &Xxx{...})
	return nil
}

模範解答

ファイル: payment/payment.go

package payment

import (
	"errors"
	"fmt"
	"regexp"
)

// ── 設計判断 ──────────────────────────────────────────
// ケース1(残高不足)    : カスタムエラー型。呼び出し元が Amount/Balance という
//                          動的な値をログ・UI表示のために取り出す必要があるため。
// ケース2(決済停止)    : センチネルエラー。「停止中かどうか」という状態フラグの
//                          二値判定のみで、追加情報が一切不要なため。
// ケース3(カード形式)  : カスタムエラー型。Field/Reasonという動的な値をフォーム
//                          のエラー表示に使う必要があるため。
// ケース4(限度額超過)  : センチネル的な扱い(固定値のfmt.Errorf + 公開定数)。
//                          MaxAmountは呼び出し元にとって「常に同じ値」であり、
//                          リトライ判定にも使わないため、フィールド付きのカスタム
//                          型を作るのは過剰設計(YAGNI違反)。公開定数として
//                          MaxAmountを公開しておけば、呼び出し元はメッセージを
//                          パースせずとも限度額そのものを参照できる。
// ─────────────────────────────────────────────────────

// ErrPaymentSuspended はシステム全体が決済停止中であることを示すセンチネルエラー。
var ErrPaymentSuspended = errors.New("payment system is currently suspended")

// MaxAmount は1回の決済における上限金額。呼び出し元からも参照できるよう公開する。
const MaxAmount = 100000

// InsufficientBalanceError は残高不足を表すカスタムエラー型。
// 呼び出し元は不足額・現在の残高という動的な値を必要とするため型で持ち運ぶ。
type InsufficientBalanceError struct {
	Amount  int // 決済しようとした金額
	Balance int // 現在の残高
}

func (e *InsufficientBalanceError) Error() string {
	return fmt.Sprintf("insufficient balance: amount=%d balance=%d shortfall=%d",
		e.Amount, e.Balance, e.Amount-e.Balance)
}

// InvalidCardError はカード情報の形式不正を表すカスタムエラー型。
// 呼び出し元はどのフィールドが・なぜ不正だったかを取り出してフォームに反映する。
type InvalidCardError struct {
	Field  string
	Reason string
}

func (e *InvalidCardError) Error() string {
	return fmt.Sprintf("invalid card field %q: %s", e.Field, e.Reason)
}

type Card struct {
	CardNumber string
	ExpiryDate string
	CVV        string
}

var (
	cardNumberRe = regexp.MustCompile(`^\d{16}$`)
	expiryDateRe = regexp.MustCompile(`^(0[1-9]|1[0-2])/\d{2}$`)
	cvvRe        = regexp.MustCompile(`^\d{3}$`)
)

// validateCard はカードの形式を検証し、最初に見つかった不正だけを返す。
func validateCard(card Card) error {
	if !cardNumberRe.MatchString(card.CardNumber) {
		return &InvalidCardError{Field: "CardNumber", Reason: "must be 16 digits"}
	}
	if !expiryDateRe.MatchString(card.ExpiryDate) {
		return &InvalidCardError{Field: "ExpiryDate", Reason: "must be in MM/YY format"}
	}
	if !cvvRe.MatchString(card.CVV) {
		return &InvalidCardError{Field: "CVV", Reason: "must be 3 digits"}
	}
	return nil
}

// Charge は決済を実行する。判定順序は「システム状態 → カード形式 → 限度額 → 残高」。
// 早い段階の異常ほど後段のチェックより先に検出したいため、この順序にしている。
func Charge(accountBalance int, amount int, card Card) error {
	if suspended() {
		return fmt.Errorf("payment.Charge: %w", ErrPaymentSuspended)
	}

	if err := validateCard(card); err != nil {
		return fmt.Errorf("payment.Charge: %w", err)
	}

	if amount > MaxAmount {
		return fmt.Errorf("payment.Charge: amount %d exceeds max limit of %d", amount, MaxAmount)
	}

	if amount > accountBalance {
		return fmt.Errorf("payment.Charge: %w", &InsufficientBalanceError{
			Amount:  amount,
			Balance: accountBalance,
		})
	}

	return nil
}

// suspended はシステムが決済停止中かどうかを返す(デモ用に固定値)。
func suspended() bool {
	return false
}

ファイル: main.go(同一モジュール内でexample.com/paymentとしてimport)

package main

import (
	"errors"
	"fmt"

	"example.com/payment"
)

func main() {
	validCard := payment.Card{CardNumber: "4242424242424242", ExpiryDate: "12/28", CVV: "123"}

	// ケース1: 残高不足 → errors.As で Amount/Balance を取り出す
	err := payment.Charge(1000, 5000, validCard)
	fmt.Println("ケース1 エラー全文:", err)
	var balErr *payment.InsufficientBalanceError
	if errors.As(err, &balErr) {
		fmt.Printf("  → 不足額=%d 現在残高=%d\n", balErr.Amount-balErr.Balance, balErr.Balance)
	}

	// ケース2: 決済停止 → errors.Is でリトライ判定
	// (suspended() は固定でfalseを返す実装のため、ここでは判定ロジックの実演のみ行う)
	suspendedErr := fmt.Errorf("payment.Charge: %w", payment.ErrPaymentSuspended)
	fmt.Println("ケース2 エラー全文:", suspendedErr)
	if errors.Is(suspendedErr, payment.ErrPaymentSuspended) {
		fmt.Println("  → リトライキューに積む")
	}

	// ケース3: 不正なカード番号 → errors.As で Field/Reason を取り出す
	invalidCard := payment.Card{CardNumber: "1234", ExpiryDate: "12/28", CVV: "123"}
	err = payment.Charge(10000, 5000, invalidCard)
	fmt.Println("ケース3 エラー全文:", err)
	var cardErr *payment.InvalidCardError
	if errors.As(err, &cardErr) {
		fmt.Printf("  → フィールド=%s 理由=%s\n", cardErr.Field, cardErr.Reason)
	}

	// ケース4: 限度額超過 → メッセージに MaxAmount が含まれることを確認
	err = payment.Charge(1000000, 200000, validCard)
	fmt.Println("ケース4 エラー全文:", err)
	fmt.Printf("  → 公開定数からも参照可能: payment.MaxAmount=%d\n", payment.MaxAmount)
}
▶ 実行結果を見る(go run . で検証済み)
ケース1 エラー全文: payment.Charge: insufficient balance: amount=5000 balance=1000 shortfall=4000
  → 不足額=4000 現在残高=1000
ケース2 エラー全文: payment.Charge: payment system is currently suspended
  → リトライキューに積む
ケース3 エラー全文: payment.Charge: invalid card field "CardNumber": must be 16 digits
  → フィールド=CardNumber 理由=must be 16 digits
ケース4 エラー全文: payment.Charge: amount 200000 exceeds max limit of 100000
  → 公開定数からも参照可能: payment.MaxAmount=100000

paymentパッケージとmain.goを1つのモジュール(go.modmodule example.com/xxxと宣言し、payment/payment.goを配置)として実行しています。単一ファイルで試す場合は、両ファイルの内容をpackage mainにまとめ、payment.プレフィックスを除去してください。

🪜 Step-by-Step 解説

1
4ケースを2軸で分類する
「動的な追加情報が要るか」「その情報が固定値か可変値か」の2軸で考えると、ケース1・3は可変値が必要(カスタム型)、ケース2は情報自体が不要(センチネル)、ケース4は情報は要るが固定値(センチネル的にfmt.Errorfへ焼き込む、または公開定数で解決)という4通りに自然に分かれます。この分類を先にコメントとして書き出しておくことで、実装中に「とりあえずカスタム型にしておこう」という安易な判断を防げます。
2
センチネルエラーとカスタム型を定義する
var ErrPaymentSuspended = errors.New("payment system is currently suspended")
const MaxAmount = 100000

type InsufficientBalanceError struct { Amount, Balance int }
type InvalidCardError struct { Field, Reason string }
MaxAmountconstとして公開している点がポイントです。カスタム型を作らずとも、呼び出し元はpayment.MaxAmountを直接参照でき、エラーメッセージの文字列をパースする必要がありません。「エラー型を増やす」以外にも「関連する値を公開する」という選択肢があることを覚えておくと、過剰設計を避けやすくなります。
3
検証ロジックを実装し、適切なエラーを返す
validateCardは複数の検証項目のうち「最初に見つかった不正」だけを返す設計にしています。全項目のエラーをまとめて返す設計(errors.Joinの活用)も可能ですが、今回はフォームの1フィールドずつのインライン検証を想定し、シンプルさを優先しました。設計判断には常に「今回の要件にとって十分な複雑さはどこまでか」という基準が伴います。
4
すべてのエラーを%wでラップしてChargeから返す
return fmt.Errorf("payment.Charge: %w", &InsufficientBalanceError{...})
Chargeという1つの関数から複数種類のエラーが返り得るため、どのエラーであっても「payment.Chargeで起きた」という文脈を一律で追加しています。これにより、呼び出し元はerrnilでなければまずpayment.Charge起因だと分かり、その後errors.Is/errors.Asで種類を判定する、という2段階の情報取得ができます。
5
呼び出し元でerrors.Is/errors.Asを使い分ける
if errors.As(err, &balErr) { ... }   // 値を取り出したい → As
if errors.Is(err, payment.ErrPaymentSuspended) { ... } // はい/いいえだけでよい → Is
呼び出し元のコードを見ると、センチネルエラーは「分岐条件」として、カスタムエラー型は「データの取り出し元」として使われていることが分かります。この役割の違いこそが設計判断の本質です。

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

📌
「型を増やすコスト」を常に意識する: Goにはenumやunion型(Rustのような)がなく、エラーの種類を表現する手段は基本的に「値」か「型」の2つしかありません。型を1つ増やすということは、その型のためのコンストラクタ・Error()メソッド・(必要なら)Unwrap()メソッドを一式追加するということであり、決して無料ではありません。だからこそGoのエコシステムでは「本当に追加情報が必要になった瞬間にだけ型を作る」という判断が重視され、これはYAGNI原則そのものの実践です。
📌
「情報を持たせる」と「型を作る」は別の話: ケース4(限度額超過)が示すように、エラーメッセージに具体的な値を含めることと、その値をフィールドとして構造化することはイコールではありません。値が実行時に変わらない(コンパイル時定数)のであれば、fmt.Errorfで焼き込むか、公開定数として別途提供すれば十分なケースが多く、わざわざ構造体を作る必要はありません。この区別ができないと「情報を含むエラーは全部カスタム型にする」という過剰なルールに陥りがちです。

🌐 他言語との比較

観点GoJavaPythonTypeScript
「値だけで十分なエラー」の表現パッケージレベルのvar Err... = errors.New(...)専用の例外クラスを作ることが多い(軽量な代替が言語標準にない)モジュールレベルの例外クラス、またはシンプルにValueErrorを使い回すErrorのサブクラス、またはリテラル型のUnion
「追加情報を持つエラー」の表現フィールドを持つ構造体 + Error()メソッド例外クラスにフィールドとgetterを追加例外クラスの__init__に属性を追加Errorを継承したクラスにプロパティを追加
型を増やすコスト感構造体1つ + メソッド1つで済み比較的軽いクラス階層の設計(継承・チェック例外か非チェック例外か)まで考慮が必要で重め動的型付けのため型を増やすコストは低いが、型チェッカーの恩恵は薄いインターフェース/クラスの追加は軽いが、instanceof判定や型ガードの記述が伴う
「情報を持つが型は増やしたくない」場合の代替定数の公開 + fmt.Errorfでのメッセージ埋め込み例外メッセージに文字列で埋め込むことが多い(構造化は諦めがち)f-stringでメッセージに埋め込むテンプレートリテラルでメッセージに埋め込む

Goで際立つのは、「型を作るコスト」が言語機能として明示的に小さく保たれている(継承階層やチェック例外の設計を考える必要がない)反面、その分「本当に型を作るべきか」という判断を毎回エンジニアが意識的に下す必要がある、という点です。他言語では「とりあえず例外クラスを作る」文化が強い場面でも、Goでは「センチネルで足りないか」を先に検討する習慣が根付いています。

🏆 実務での使いどころ

  • 公開SDK/ライブラリの設計: 外部チームや社外の利用者が使うライブラリでは、エラー型を安易に増やすと後方互換性の負債になるため、今日学んだ判断基準に基づいて「本当にカスタム型が必要か」を設計レビューの観点に組み込む
  • バリデーションエラーの構造化API化: InvalidCardErrorのように、フィールド名・理由を持つカスタム型は、HTTPハンドラで400 Bad RequestのJSONレスポンス({"field": "...", "reason": "..."})に直接変換できる形で設計しておくと、ハンドラ層のコードが薄くなる
  • リトライ制御の設計: ErrPaymentSuspendedのような状態系のセンチネルエラーは、リトライキュー・サーキットブレーカーの判定ロジックに直結する。「このエラー『だけ』はリトライしてよい」という許可リストの設計にそのまま使われる
  • 限度額・設定値の公開: MaxAmountのように、呼び出し元が事前にバリデーションできる値は、エラーが起きてから知るのではなく公開定数・公開関数(payment.MaxAmount)として先出しし、エラー自体をシンプルに保つ設計は多くのAPI/SDKで採用されている

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
すべてのエラーをカスタム型にしてしまう(過剰設計)「情報を持たせられる方が安全」という直感追加情報が不要、または固定値で足りるならセンチネルや定数で十分。型を増やすたびに保守コストとAPIの複雑さが増す
すべてをセンチネルで済ませようとするセンチネルの定義が1行で書けるため楽「どのフィールドが」「いくつ超過したか」のような動的な値を呼び出し元が必要とする場合、センチネルでは情報を運べず、文字列パースという不安定な手段に頼ることになる
カスタム型のフィールドを後から気軽にリネーム・削除する公開APIという意識が薄いエクスポートされた構造体のフィールドはAPIの一部。リネーム・削除は破壊的変更になるため、公開前の設計段階で慎重に決める必要がある
判定ロジック(errors.Is/errors.As)を書く前に、そもそもどちらの設計が要件に合うか検討しない実装を先に書き始めてしまう習慣エラー型の設計は「呼び出し元が何を必要とするか」から逆算するべきで、実装の都合(書きやすさ)を優先すると後から使いにくいAPIになりがち

🚀 次のステップ

  • 発展: InvalidCardErrorerrors.Joinを使って「全フィールドの不正をまとめて返す」設計に拡張し、その場合に呼び出し元のerrors.Asの挙動がどう変わるか(複数該当時にどの値が取れるか)を確認してみましょう
  • 次回予告: Day 020 — インターフェース設計の原則(小さいインターフェース)(概念理解→設計)。「Accept interfaces, return structs」というGoの格言を、今日のエラー型設計と同じ「本当に必要な最小限は何か」という視点で掘り下げます

🎯 自己評価

自分の回答

気づき・メモ