STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

クライアントサイドのバリデーションエラーのデータ型についての考察

業務委託で STORES の開発をしている @inouetakuya です。

先日 STORES のフロントエンドチーム内でクライアントサイドのバリデーションについて見直す機会があり、特にバリデーションエラーのデータ型をどうするかについての議論が興味深かったので、共有させていただきます。

背景

議論の背景について簡単に触れておくと、STORES のクライアントサイドでは、バリデーションのライブラリとしてこれまで joi-browser を使ってきました。

しかしながら、本家の Node.js 版の joi がブラウザ対応したことにより joi-browser が deprecated になったことを受けて、今後も joi を使い続けるかを検討したところ、

  • joi-browser と joi の最新バージョンとの間で API の差異がいくつかあり、joi-browser から joi への乗り換え時に書き換えのコストがかかる
  • 実際にプロダクトで使っているのは required / min / max / regex くらいで、joi の豊富にある API を覚えたり、使い方を学ぶコストをペイできていない現状

という理由で、joi-browser から joi への乗り換えは見送り、今後新規に書くコードについては joi を使わずにバリデーションを行うことに決めました。

また、

実際にプロダクトで使っているのは required / min / max / regex くらいで、joi の豊富にある API を覚えたり、使い方を学ぶコストをペイできていない現状

あたりは、joi 以外のバリデーションライブラリにも当てはまり、現状ではライブラリを使わずにバリデーションを行うのが良いだろうと判断しました。

何らかのバリデーションライブラリを使っていれば、バリデーションエラーのデータ型はライブラリが既定していることが多いと思いますが、ライブラリを使わずにバリデーションを行うと決めたので、自由な発想でさいきょうのデータ型を考えることになった、というわけです。

どのような流れで考えていったのかを以下でご紹介します。

Phase 1. シンプル

さて、思いつく最もシンプルなデータ型は下記のような感じでしょう。

type Errors = string[]

この型ではバリデーション時にエラーメッセージを push するかたちになります。

const errors: Errors = []

const validate = (): void => {
  if (...) errors.push('名前を入力してください')
  if (...) errors.push('名前は20文字以内で入力してください')
}

このままでも事足りるケースはあるかもしれませんが、テストが脆くなるという弱点を抱えています。

つまり、テストコードは下記のようになりますが、この書き方ではエラーメッセージを変更するとテストが落ちてしまいます。

describe('validate', () => {
  test('required', () => {
    validate('')
    expect(errors).toContain('名前を入力してください')
  })
})

エラーメッセージという変更が入りやすい箇所にテストの結果が依存してしまっているのはあまり良い状況とはいえません。

また、どのようなエラーでも自由に入れることができてしまうので、どのようなエラーが入りうるのか事前に把握しづらいという弱点もあります。

せっかく TypeScript を使っているので、型によりどのようなエラーが入りうるのか把握できるようになるとより良いでしょう。

Phase 2. エラーメッセージが変更されてもテストが落ちないようにする

Phase 1 から一歩進めて、エラーメッセージが変更されてもテストが落ちないようにするためには、エラーメッセージだけではなく ruleName も含んだオブジェクトを push するようにします。

type Errors = Array<{ ruleName: 'required' | 'max'; message: string }>

const validate = (): void => {
  if (...) errors.push({ ruleName: 'required', message: '名前を入力してください' })
  if (...) errors.push({ ruleName: 'max', message: '名前は20文字以内で入力してください' })
}

テストは下記のようになります。

describe('validate', () => {
  test('required', () => {
    validate('')
    expect(errors.some((error) => error.ruleName === 'required')).toBe(true)
  })
})

これでエラーメッセージが変更されてもテストが落ちないようになりました。

また、ruleName の型をエラーのユニオン型にすることで、型で発生しうるエラーを把握できるようになりました。

Phase 2. の弱点: 同一エラーの重複を許してしまう

さて、一見よさそうな Phase 2 のデータ型ですが、実は「同一エラーの重複を許してしまう」という弱点を抱えています。

例えば Tシャツという商品が Sサイズ、Mサイズ、Lサイズという 3つのサイズを持っているとします。それぞれのサイズはバーコード情報も持っており、これは他の商品のものとも重複は許されません。

  • (1) Tシャツの Sサイズのバーコードが、Mサイズや Lサイズのバーコードと重複してはいけない
  • (2) Tシャツの Sサイズのバーコードが、他の商品(例えばパーカー)のバーコードと重複してもいけない

という具合です。このとき (1) については Tシャツを編集するページ内、つまりクライアントサイドでチェックできますが、(2) については API で問い合わせてサーバーサイドでチェックするしかありません。

const validate = (): void => {
  // (1) についてのバリデーション
  if (...) errors.push({ ruleName: 'unique', message: 'バーコードが重複しています' })
  
  // ... API への問い合わせなどの処理
  
  // (2) についてのバリデーション
  if (...) errors.push({ ruleName: 'unique', message: 'バーコードが重複しています' })
}

このように同じエラーが重複して入ってしまう余地を残してしまっており、(1) についてのバリデーションと (2) についてのバリデーションのコード間に開きがあったり、それぞれを別の人が実装するなどのケースでは、より重複ミスのリスクは大きくなってしまいます。

Phase 3. 同一エラーが入り込む余地を排除する

Phase 2 のデータ型から同一エラーが入り込む余地を排除したのが下記のデータ型です。

type Errors = {
  required: { invalid: boolean; readonly message: string };
  max: { invalid: boolean; readonly message: string };
}

const errors: Errors = {
  required: { invalid: false, message: '名前を入力してください' },
  max: { invalid: false, message: '名前は20文字以内で入力してください' }
}

const validate = (): void => {
  if (...) errors.required.invalid = true;
  if (...) errors.max.invalid = true;
}

STORES のクライアントサイドでは、現在、このデータ型をバリデーションエラーの型として採用しています。

以下はこのデータ型についてのいくつかの補足です。

なぜ valid: boolean ではなく invalid: boolean なのか

Phase 3 の Errors 型について、invalid: boolean となっている箇所がなぜ valid: boolean ではないのか?について補足しておきます。

バリデーションに関する状態には、

  • (1) 未判定(初期状態)
  • (2) 判定済みでバリデーション OK
  • (3) 判定済みでバリデーションエラー

という 3つの状態があり得ます。

このうち (1) 未判定(初期状態)を、invalid ではなく valid を使って初期状態を表すと { valid: false } となります。

そうすると「valid が false のときにエラーメッセージを表示する」というロジックでは、初期状態でも誤ってエラーメッセージが表示されてしまいます。

一方で、それを防ぐために初期状態を { valid: true } にすると、まだバリデーションを行っていないという実態とコードが乖離してしまいます。

そのような理由から { invalid: boolean } を採用し「(3) 判定済みでバリデーションエラー」つまり { invalid: true } のときのみエラーメッセージを表示させるのが良いと考えました。

ただし、STORES では「(3) 判定済みでバリデーションエラー」のときにエラーメッセージを表示させるだけですが、例えばパスワードの強度判定のように「適正なときには適正であることを表示させる」という要件なのであれば、{ valid: boolean } という情報も必要になると思います。VeeValidate のように、valid と invalid 両方のフラグを用意しているライブラリもあります。

エラーメッセージをエラーデータに含めるかテンプレートに直接書くか

ところで、これまで見てきた例ではエラーメッセージをエラーデータの一部に含めていましたが、テンプレートに直接書くという選択肢もあると思います。

例えば Vue.js だと下記のような感じです。

<ul>
  <li v-if="errors.required.invalid">名前を入力してください</li>
  <li v-if="errors.max.invalid">
    名前は20文字以内で入力してください。詳しくは <a>こちら</a>
  </li>
  <li v-if="errors.reqex.invalid">
    名前に使用できない文字が含まれています。名前に使用できるのは以下のとおりです
    <ul>
      <li>...</li>
      <li>...</li>
      <li>...</li>      
    </ul>
  </li>
</ul>

例にあるように、エラーメッセージにリンクを含ませたいときや、エラーメッセージを装飾したいときなどに役立ちます。

一方で、エラーの種類が多いと、その数だけ v-if="..." などを記述しなければならず、記述に手間がかかります。

そこで、原則、エラーメッセージをエラーデータに含めて下記のように書くことにして、

<ul>
  <!-- errorMessages は errors から抽出した Array<string> -->
  <li v-for="errorMessage in errorMessages">
    {{ errorMessage }}
  </li>
</ul>

エラーメッセージにリンクを含ませたいときや、エラーメッセージを装飾したいときなどの特別な場合にはテンプレートにエラーメッセージを書く、というのが良いと考え、現在、そのように運用しています。

おわりに

バリデーションというのはほとんどのアプリケーションで実装するものだと思いますが、にもかかわらず、その基礎となるバリデーションエラーのデータ型については、これがベストプラクティスだ!という決定的な情報を見つけることが難しく、各々でライブラリなどを参考にしながら検討しているというのが現状だと想像します。

そうした想像もあり、今回 STORES のフロントエンドチームで行われた議論を共有させていただきました。これが少しでも参考になれば幸いです。ではでは。