Go のバリデーションエラーの設計

Web アプリケーションでは、ユーザーからの入力を検証し、不正な値があれば適切なエラーメッセージを返す必要がある。Go では標準ライブラリだけでもバリデーションエラーの仕組みを設計できるが、構造体のフィールドごとにエラーを収集し、まとめてクライアントに返すには少し工夫がいる。

単純な方法とその限界

最初に思いつくのは、バリデーション関数で最初のエラーを即座に返す方法だろう。

func ValidateUser(name string, age int) error {
	if name == "" {
		return errors.New("name is required")
	}
	if age < 0 || age > 150 {
		return errors.New("age must be between 0 and 150")
	}
	return nil
}

この方法はシンプルだが、最初に見つかったエラーしか返せない。フォーム入力で名前も年齢も両方おかしい場合、ユーザーは 1 つ直してまた送信し、次のエラーを見てまた直す……という往復が発生してしまう。API でもフロントエンドでも、すべてのバリデーションエラーを一括で返すのが望ましい。

フィールド単位でエラーを収集する

複数のエラーをまとめて返すには、フィールド名とエラーメッセージの対応を保持する型を定義するとよい。

type ValidationErrors map[string]string

func (v ValidationErrors) Error() string {
	msgs := make([]string, 0, len(v))
	for field, msg := range v {
		msgs = append(msgs, field+": "+msg)
	}
	return strings.Join(msgs, "; ")
}

func (v ValidationErrors) HasErrors() bool {
	return len(v) > 0
}

ValidationErrors は map[string]string をベースにした型で、error インターフェースを満たしている。キーがフィールド名、値がエラーメッセージという構造になる。Error() メソッドで全フィールドのエラーを結合した文字列を返すため、ログ出力やデバッグ時にも使いやすい。

この型を使ってバリデーション関数を書き直すと、以下のようになる。

func ValidateUser(name string, email string, age int) error {
	errs := make(ValidationErrors)

	if name == "" {
		errs["name"] = "name is required"
	}
	if !strings.Contains(email, "@") {
		errs["email"] = "invalid email format"
	}
	if age < 0 || age > 150 {
		errs["age"] = "must be between 0 and 150"
	}

	if errs.HasErrors() {
		return errs
	}
	return nil
}

すべてのフィールドを検証してから、エラーがあればまとめて返す。なければ nil を返すだけだ。

構造体を受け取る形に整理する

実際のアプリケーションでは、バリデーション対象はリクエストボディを構造体にバインドしたものであることが多い。構造体を直接受け取る形に整理しておくと、ハンドラとの連携がスムーズになる。

type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func (r CreateUserRequest) Validate() error {
	errs := make(ValidationErrors)

	if r.Name == "" {
		errs["name"] = "name is required"
	}
	if len(r.Name) > 100 {
		errs["name"] = "name must be 100 characters or less"
	}
	if !strings.Contains(r.Email, "@") {
		errs["email"] = "invalid email format"
	}
	if r.Age < 0 || r.Age > 150 {
		errs["age"] = "must be between 0 and 150"
	}

	if errs.HasErrors() {
		return errs
	}
	return nil
}

リクエスト構造体に Validate() メソッドを生やすパターンは Go の Web 開発でよく使われる。構造体自身がバリデーションロジックを持つため、ハンドラ側のコードが簡潔になるのが利点だ。

JSON レスポンスとして返す

単一エラー返却

最初の 1 つだけ返すので実装は楽だが、ユーザーが何度も修正・再送信を繰り返す羽目になる

一括エラー返却

全フィールドのエラーをまとめて返すので、ユーザーは 1 回で全修正箇所を把握できる

バリデーションエラーをクライアントに返すとき、フィールドごとのエラーを JSON で表現できると、フロントエンド側でフィールドの横にエラーメッセージを表示するといった処理が容易になる。

func handleCreateUser(w http.ResponseWriter, r *http.Request) {
	var req CreateUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	if err := req.Validate(); err != nil {
		var ve ValidationErrors
		if errors.As(err, &ve) {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusUnprocessableEntity)
			json.NewEncoder(w).Encode(map[string]any{
				"errors": ve,
			})
			return
		}
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{
		"message": "user created",
	})
}

errors.As で ValidationErrors 型かどうかを判定し、フィールドごとのエラーマップをそのまま JSON にエンコードしている。ステータスコードは 422 Unprocessable Entity を使うのが一般的で、リクエストの構文自体は正しいがセマンティクスに問題があることを示す。

レスポンスは以下のような形になる。

{
  "errors": {
    "name": "name is required",
    "email": "invalid email format",
    "age": "must be between 0 and 150"
  }
}

Validator インターフェースで汎用化する

複数のリクエスト構造体に対して同じパターンを適用するなら、インターフェースを定義しておくと便利だ。

type Validator interface {
	Validate() error
}

func validateAndRespond(w http.ResponseWriter, v Validator) bool {
	if err := v.Validate(); err != nil {
		var ve ValidationErrors
		if errors.As(err, &ve) {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusUnprocessableEntity)
			json.NewEncoder(w).Encode(map[string]any{
				"errors": ve,
			})
			return false
		}
		http.Error(w, err.Error(), http.StatusBadRequest)
		return false
	}
	return true
}

Validate() error を持つ型であれば何でも受け取れるため、ハンドラごとにバリデーションエラーの処理を書く必要がなくなる。ハンドラ側は以下のように簡潔に書ける。

func handleCreateUser(w http.ResponseWriter, r *http.Request) {
	var req CreateUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	if !validateAndRespond(w, &req) {
		return
	}

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{
		"message": "user created",
	})
}

1 フィールドに複数エラーを持たせる拡張

ここまでの設計では 1 フィールドに 1 エラーだったが、「必須かつ最大文字数制限」のように複数のルールを同時に違反するケースもある。そうした場合は map の値をスライスに変えることで対応できる。

type ValidationErrors map[string][]string

func (v ValidationErrors) Error() string {
	msgs := make([]string, 0)
	for field, fieldMsgs := range v {
		for _, msg := range fieldMsgs {
			msgs = append(msgs, field+": "+msg)
		}
	}
	return strings.Join(msgs, "; ")
}

func (v ValidationErrors) Add(field, message string) {
	v[field] = append(v[field], message)
}

func (v ValidationErrors) HasErrors() bool {
	return len(v) > 0
}

Add メソッドを用意することで、同じフィールドに複数のエラーメッセージを追加できるようになった。JSON レスポンスは以下のような形に変わる。

{
  "errors": {
    "name": ["name is required", "name must be 100 characters or less"],
    "email": ["invalid email format"]
  }
}

フロントエンド側でフィールドごとに複数のメッセージを表示したい場合には、この拡張が役に立つ。ただし、多くの実用的なケースでは 1 フィールド 1 エラーで十分なことも多いため、プロジェクトの要件に応じて選択すればよい。

まとめ

map[string]string でフィールドごとにエラーを収集

error インターフェースを実装して標準の仕組みに乗せる

errors.As で型判定し JSON レスポンスに変換

Validator インターフェースで複数構造体に汎用適用

Go の標準ライブラリだけでも、バリデーションエラーの収集・返却・汎用化まで十分に設計できる。外部ライブラリ(go-playground/validator など)を導入する場合でも、ここで紹介した考え方はカスタムエラーの設計やレスポンス変換の部分で応用が利く。まずは自前で仕組みを理解しておくと、ライブラリの内部動作も把握しやすくなるだろう。