入力チェック(バリデーション)の考え方と設計と実装 - 実装編
2025-04-17
azblob://2025/04/25/eyecatch/2025-04-28-ek06-how-to-frontend-validation-3-000_0.png

はじめに

これまでのシリーズでは、Webフォームにおけるバリデーション(入力チェック)について

段階的に解説してきました。

第一回「入力チェック(バリデーション)の考え方と設計と実装 - 考え方編」では、

バリデーションの基本的な役割や実装における注意ポイントについて解説しました。

フロントエンドでのバリデーションがユーザビリティの向上やサーバー負荷軽減に貢献すること、

入力例を常に表示することの重要性、適切なチェックタイミングの選択、

そして入力規則の明示的な表示などの基本原則を紹介しました。

第二回「入力チェック(バリデーション)の考え方と設計と実装 - 設計編」では、

バリデーション設計のプロセスについてユーザー登録フォームを例に具体的に解説しました。

要件定義から始まり、設計ドキュメントの作成、そして視覚的なイメージの作成まで、

一連の流れを紹介しました。

本記事では、設計編で作成した要件と設計ドキュメントに基づいて、

実際にフォームバリデーションを実装していきます。

HTML、CSS、JavaScriptを使用した基本的な実装を段階的に紹介します。

なお、本記事で紹介する設計手法や実装例はあくまで一例です。

実際のプロジェクトでは、サービスの特性やユーザー層、

技術スタックなどによって最適な方法は異なります。

「こうした方がより良いのでは?」という点があれば、

ぜひ自分のプロジェクトに合わせて改修してください。

技術スタック

本記事では、モダンなWeb開発環境を想定し、以下の技術スタックを使用して実装を行います。

Nuxt 3

Vue.jsベースのフルスタックフレームワーク

  • コンポーネントベースの開発
  • ファイルベースのルーティング
  • サーバーサイドレンダリング(SSR)対応

TypeScript

静的型付けによる安全なJavaScript開発

  • 型定義によるバリデーションルールの明確化
  • コード補完と早期エラー検出
  • メンテナンス性と可読性の向上

CSS

スタイリングの基本

  • Vuetifyのカスタマイズ
  • 状態に応じたスタイル変更
  • アニメーションとトランジション

Vuetify 3

マテリアルデザインベースのUIコンポーネントライブラリ

  • 豊富な入力コンポーネント(v-text-field, v-selectなど)
  • 組み込みのバリデーション機能
  • レスポンシブデザインのサポート
  • アクセシビリティ対応

Nuxt 3とVuetify 3の組み合わせにより、少ないコード量で高機能なフォームを実装できます。

特にVuetifyは独自のバリデーション(v-formなど)機能を持っていますが、

本記事ではその機能をほとんど使用せず、バリデーション実装を目指します。

なお、実装例はNuxt 3とVuetify 3に特化していますが、

基本的な考え方や実装パターンは他のフレームワークでも応用可能です。

Vue.js単体やReact、Angularなど他のフレームワークをお使いの方も、

アプローチの参考にしていただければ幸いです。

実装プロセス

実装は主に以下の順序で進めていきます。

なお、これは一例であり、ご自身のプロジェクトや好みに合わせて順序を変更しても問題ありません。

  1. 型の定義 - バリデーションに必要な型を定義
  2. ルールの作成 - バリデーションルールを実装
  3. コンポーネントの作成 - UI要素を構築
  4. バリデーション処理の追加 - 作成したコンポーネントにバリデーションロジックを組み込み

なお、実装の詳細なコード例は記事の最後に掲載しています

興味のある方はぜひ参照してください。

型の定義

まず、実装する前に、今回実装する必要があるのは何なのかを整理したいと思います。

 以下の図を見てください。これはフォームを構成する基本的な要素です。

フォームの基本要素

これらのそれぞれの要素について考えていきます。

ルール

ルール

フィールド

フィールド

フォーム

フォーム

これらをもとに type , interface を作成します。

ルールの作成

作成した型を元にして、バリデーションルールを作成します。

文字数チェックや禁止文字チェックなどのルールは汎用性を高めるために

引数で値を指定できると良いと思います。

また、必須チェックを行うルールも、値の型によってルールを分けるのではなく、

一律で一つの必須チェックルールとして、中身で型に応じて処理を変更するなど、

あとあと機能追加がしやすいように作りましょう。

今回は設計編で示したルールのうち一部を作成しました。

コンポーネントの作成

次にコンポーネントを作成していきます。

ここでは扱う情報量が少ないものから順に作成していきましょう。

今回の場合は「ツールチップ」>「フィールド」>「フォーム」の順番に作成します。

まずは、バリデーションロジックなどは気にせずにUI部分を中心に実装し、

あとからバリデーションの処理を加えていくように設計すると、修正が少なくて済みます。

バリデーション処理の追加

最後に作成したコンポーネントに対して、バリデーション処理を追加していきます。

Vuetifyのコンポーネントにはエラー状態が標準で用意されているので、

バリデーション結果とエラーメッセージを渡すだけで簡単にスタイルを変更することができます。

今回はチェックを行うタイミングを考慮した以下のような処理を実装しました。

基本的なバリデーション処理イベント別のバリデーション処理

完成したもの

実際に完成したものが以下になります。

完成したもの

それらしいものができたと思います。

こちらの実装には以下のような特徴があります。

テキストフィールドにフォーカスすると、対応するルールが記載されたツールチップが表示されます。

これによりユーザーは入力前に求められる条件を確認できます。

動作1

テキストフィールドに値を入力すると、ツールチップ内の判定はリアルタイムに行われますが、

フィールドへのフィードバックはルールに設定したタイミングで行われます。

例えば、「文字数制限」は入力中にリアルタイムでフィードバックされますが、

「禁止文字」のフィードバックはフォーカスを外したときに行われるといった具合です。

実装2

フォーム内の全てのフィールドでエラーが無い状態で、「登録(submit)ボタン」をクリックすると、

バリデーションチェックは成功し、フォームの送信処理が実行されます。

実際の本番環境では、これらのクライアントサイドバリデーションに加えて、

CSRF対策やXSS対策などのセキュリティ処理を実装する必要があります。

フロントエンドのバリデーションはユーザビリティのためのものであり、

セキュリティの観点からはサーバーサイドでの検証が必須であることを忘れないようにしましょう。

処理3

機能拡張

今回はサンプルということで、基本的な機能のみを実装しました。

実際のプロジェクトでは、以下のような機能拡張が考えられます。

追加バリデーションルール

  • 複雑な形式チェック:メールアドレスや電話番号、郵便番号など特定のフォーマットに対応したルール
  • 非同期バリデーション:ユーザー名重複チェック(API経由)など、サーバーと通信して結果を得るルール
  • 条件付きバリデーション:他のフィールドの値に応じて検証ルールを変更する機能

対応コンポーネントの拡張

  • 様々な入力コンポーネント:テキストフィールドだけでなく、ファイルインプット、プルダウン、チェックボックス、ラジオボタンなど多様な入力要素に対応
  • カスタムコンポーネント:特殊な入力形式(日付範囲選択、住所入力など)に対応したコンポーネント

UI/UX改善

  • エラーダイアログ:エラー内容を配列で持つようにしているため、登録ボタンを押した時に発生しているエラーの一覧をダイアログで表示することも可能
  • フォームレベルエラー表示:scopeが"form"のエラーメッセージのみ、フォーム上部にまとめて表示する機能

 

これらの拡張機能は、今回実装したベースの構造を活かしながら、必要に応じて追加していくことができます。

型定義とコンポーネント設計を適切に行ったことで、拡張性の高いシステムになっていると言えるでしょう。

終わりに

本記事では、Nuxt 3とVuetify 3を使用したフォームバリデーションの実装方法を詳しく解説しました。

型定義からルール作成、コンポーネント実装まで段階的に進めることで、

保守性が高く拡張しやすいバリデーションシステムを構築できることを示しました。

これで「Webフォームのバリデーション」シリーズ全3回が完結しました。

  • 第一回「考え方編」では、バリデーションの基本概念や重要性、ユーザビリティとセキュリティのバランスについて解説しました。
  • 第二回「設計編」では、要件定義から設計ドキュメント作成までのプロセスを具体的なユーザー登録フォームの例を通して紹介しました。
  • 第三回「実装編」では、設計を実際のコードに落とし込み、動作するバリデーションシステムを構築しました。

このシリーズを通して、単なる「入力チェック」を超えた、

ユーザーフレンドリーで堅牢なバリデーションの実現方法をお伝えできたと思います。

フォームは多くのWebサービスにおいて、ユーザーとの最初の接点となる重要な要素です。

適切に設計・実装されたバリデーションは、ユーザー体験を向上させるだけでなく、

開発者にとっても保守性の高いコードベースを実現します。

今回紹介した実装パターンはあくまで一例です。

皆さんのプロジェクトに合わせて柔軟にアレンジし、

より良いフォーム体験を提供するための参考にしていただければ嬉しいです。

このシリーズが皆さんの開発の一助となれば幸いです。

第一回:入力チェック(バリデーション)の考え方と設計と実装 - 考え方編

第二回:入力チェック(バリデーション)の考え方と設計と実装 - 設計編

第三回:入力チェック(バリデーション)の考え方と設計と実装 - 実装編

実装例

環境構築

新規プロジェクトを作成します。

pnpm dlx nuxi init validation-app

プロジェクトに移動し、VSCodeで開きます。

cd validation-app
code .

必要なライブラリをインストールします。

// Vuetify 3のインストール
pnpm add vuetify@latest @mdi/font

// TypeScript関連のライブラリ
pnpm add -D typescript @types/node

環境設定を行います。

~/nuxt.config.ts

TypeScript
export default defineNuxtConfig({
  compatibilityDate: "2024-11-01",
  devtools: { enabled: true },
  modules: ["@nuxt/eslint", "@nuxt/fonts", "@pinia/nuxt"],
  css: ["@/assets/scss/main.scss", "@mdi/font/css/materialdesignicons.css"],
  build: {
    transpile: ["vuetify"]
  }
});

~/assets/scss/main.scss

TypeScript@use "vuetify/styles";

~/plugins/vuetify.ts

TypeScriptimport { createVuetify } from "vuetify";
import * as components from "vuetify/components";
import * as directives from "vuetify/directives";

export default defineNuxtPlugin((nuxtApp) => {
  const vuetify = createVuetify({
    components,
    directives,
    theme: {
      defaultTheme: "light"
    }
  });

  nuxtApp.vueApp.use(vuetify);
});

型定義

次にバリデーションチェックに使用する型を定義していきます。

~/validation/type.ts

TypeScript/** バリデーションのチェックタイミング */
export type ValidationTiming =
  | "mount" // マウント時
  | "input" // リアルタイム時
  | "blur" // ブラー時
  | "submit"; //サブミット時

/** バリデーションのチェック対象 */
export type ValidationScope =
  | "field" //フィールド
  | "form"; // フォーム

/** 入力値の型 */
export type ValidationValue =
  | string
  | number
  | boolean
  | null
  | undefined
  | File
  | FileList
  | Record<string, unknown>;

/** ツールチップ用バリデーション状態 */
export type ValidationRuleState =
  | "default" // チェック前
  | "error" // チェック後不合格
  | "success"; // チェック後合格

/** バリデーションルール */
export interface ValidationRule {
  // 一意に識別する文字列
  id: string;
  // 優先順位(0が最優先)
  priority: number;
  // チェックタイミング(複数可)
  timing: ValidationTiming[];
  // チェックする対象
  scope: ValidationScope;
  // 判定式
  validate: (value?: ValidationValue) => boolean;
  // エラーメッセージ
  message: string;
  // バリデーション結果
  isValid: boolean;
  // バリデーション状態
  state: ValidationRuleState;
}

/** バリデーションフィールド */
export interface ValidationField {
  // 一意に識別する文字列
  id: string;
  // 表示名
  name: string;
  // 入力例
  example?: string;
  // バリデーションルール一覧
  rules: ValidationRule[];
  // エラーメッセージ一覧
  messages: string[];
  // バリデーション結果
  isValid: boolean;
}

/** バリデーションフォーム */
export interface ValidationForm {
  // 一意に識別する文字列
  id: string;
  // 表示名
  name: string;
  // バリデーションフィールド一覧
  fields: ValidationField[];
  // バリデーション結果
  isValid: boolean;
}

ルール作成

~/validation/rules.ts

TypeScriptimport type { ValidationRule, ValidationValue } from "./types";

/**
 * 必須判定式
 */
export const createRequiredRule = (): ValidationRule => ({
  id: "required",
  priority: 0,
  timing: ["blur", "submit"],
  scope: "field",
  validate: (value?: ValidationValue): boolean => {
    if (value === undefined || value === null || value === "") {
      return false;
    }

    // FileListやFile型の場合
    if (value instanceof FileList) {
      return value.length > 0;
    }

    // 配列の場合
    if (Array.isArray(value)) {
      return value.length > 0;
    }

    return true;
  },
  message: "必須項目です",
  isValid: false,
  state: "default"
});

/**
 * 文字数制限判定式
 */
export const createStringsLengthRule = (
  max: number,
  min: number,
  message?: string
): ValidationRule => ({
  id: "stringsLength",
  priority: 5,
  timing: ["input", "submit"],
  scope: "field",
  validate: (value?: ValidationValue): boolean => {
    if (value === undefined || value === null || value === "") {
      return true;
    }

    if (typeof value !== "string") {
      return false;
    }

    if (value.length < min || value.length > max) {
      return false;
    }

    return true;
  },
  message: message || `${min}文字以上${max}文字以下で入力してください`,
  isValid: false,
  state: "default"
});

/**
 * 半角英数字とアンダースコアのみを許可する判定式
 */
export const createAlphaNumericUnderscoreRule = (
  message?: string
): ValidationRule => ({
  id: "alphaNumericUnderscore",
  priority: 10,
  timing: ["blur", "submit"],
  scope: "field",
  validate: (value?: ValidationValue): boolean => {
    if (value === undefined || value === null || value === "") {
      return true;
    }

    if (typeof value !== "string") {
      return false;
    }

    // 正規表現で半角英数字とアンダースコアのみをチェック
    const regex = /^[a-zA-Z0-9_]+$/;
    return regex.test(value);
  },
  message: message || "半角英数字とアンダースコアのみ使用できます",
  isValid: false,
  state: "default"
});

/**
 * 特定の文字列を禁止する判定式
 */
export const createForbiddenValuesRule = (
  forbiddenValues: string[],
  caseSensitive: boolean = false,
  message?: string
): ValidationRule => ({
  id: "forbiddenValues",
  priority: 20,
  timing: ["blur", "submit"],
  scope: "field",
  validate: (value?: ValidationValue): boolean => {
    if (value === undefined || value === null || value === "") {
      return true;
    }

    if (typeof value !== "string") {
      return false;
    }

    // 大文字小文字を区別しない場合
    if (!caseSensitive) {
      const lowerValue = value.toLowerCase();
      return !forbiddenValues.some(
        (forbidden) => lowerValue === forbidden.toLowerCase()
      );
    }

    // 大文字小文字を区別する場合
    return !forbiddenValues.includes(value);
  },
  message: message || "使用できない値が含まれています",
  isValid: false,
  state: "default"
});

/**
 * 特定のフィールドと完全一致のみ許可する判定式
 */
export const createMatchFieldRule = (
  fieldName: string,
  fieldValue?: string,
  message?: string
): ValidationRule => ({
  id: "matchField",
  priority: 30,
  timing: ["blur", "submit"],
  scope: "form",
  validate: (value?: ValidationValue): boolean => {
    // どちらかがundefinedの場合
    if (fieldValue === undefined || value === undefined) {
      return false;
    }

    if (typeof value !== "string") {
      return false;
    }

    return fieldValue === value;
  },
  message: message || `${fieldName}と一致する必要があります`,
  isValid: false,
  state: "default"
});

/**
 * 特定のフィールドの値を含んでいた場合拒否する判定式
 */
export const createNotContainFieldRule = (
  fieldName: string,
  fieldValue?: string,
  caseSensitive: boolean = false,
  message?: string
): ValidationRule => ({
  id: "notContainField",
  priority: 40,
  timing: ["blur", "submit"],
  scope: "form",
  validate: (value?: ValidationValue): boolean => {
    // どちらかがundefinedの場合
    if (fieldValue === undefined || value === undefined || !fieldValue) {
      return true;
    }

    if (typeof value !== "string") {
      return false;
    }

    // 空文字列の場合は常に有効
    if (value === "") {
      return true;
    }

    // 大文字小文字を区別しない場合
    if (!caseSensitive) {
      const lowerValue = value.toLowerCase();
      const lowerFieldValue = fieldValue.toLowerCase();
      return !lowerValue.includes(lowerFieldValue);
    }

    // 大文字小文字を区別する場合
    return !value.includes(fieldValue);
  },
  message: message || `${fieldName}を含めることはできません`,
  isValid: false,
  state: "default"
});

コンポーネント作成

最後にコンポーネントを作成していきます。

ツールチップ

~/components/Tooltip/Validation.vue

<template>
  <!-- バリデーションルールを表示するツールチップ -->
  <v-tooltip
    location="bottom start"
    :offset="-15"
    :transition="false"
    no-click-animation
    :model-value="props.isShow"
  >
    <!-- ツールチップのアクティベーター(透明な重なり要素) -->
    <template #activator="{ props: tooltipProps }">
      <div v-bind="tooltipProps" class="validation-tooltip-activator" />
    </template>

    <!-- ツールチップの内容 -->
    <div>
      <!-- 各バリデーションルールの表示 -->
      <div v-for="rule in props.rules" :key="rule.id" class="validation-rule">
        <!-- ルールの状態を示すアイコン -->
        <v-icon
          :icon="getRuleIcon(rule)"
          :color="getRuleIconColor(rule)"
          size="20"
          class="mr-2"
        />
        <!-- ルールのメッセージ -->
        <span :class="getRuleTextClass(rule)">{{ rule.message }}</span>
      </div>
    </div>
  </v-tooltip>
</template>

<script setup lang="ts">
import type { ValidationRule } from "~/validation/types";

interface Props {
  rules: ValidationRule[]; // 表示するバリデーションルールの配列
  isShow: boolean; // ツールチップの表示状態
}

const props = defineProps<Props>();

/** ルールの状態に基づいてアイコンを取得 */
const getRuleIcon = (rule: ValidationRule): string => {
  switch (rule.state) {
    case "success":
      return "mdi-check-circle"; // 成功時のチェックアイコン
    case "error":
      return "mdi-alert-circle"; // エラー時の警告アイコン
    default:
      return "mdi-circle-outline"; // デフォルトの円アイコン
  }
};

/** ルールの状態に基づいてアイコンの色を取得 */
const getRuleIconColor = (rule: ValidationRule): string => {
  switch (rule.state) {
    case "success":
      return "success"; // 成功時の緑色
    case "error":
      return "error"; // エラー時の赤色
    default:
      return "grey"; // デフォルトのグレー
  }
};

/** ルールの状態に基づいてテキストのクラスを取得 */
const getRuleTextClass = (rule: ValidationRule): string => {
  switch (rule.state) {
    case "success":
      return "text-success"; // 成功時のテキストスタイル
    case "error":
      return "text-error"; // エラー時のテキストスタイル
    default:
      return "text-grey"; // デフォルトのテキストスタイル
  }
};
</script>

<style scoped>
/* ツールチップのスタイル調整 */
:deep(.v-overlay__content) {
  background: white;
  box-shadow: 0 0 2px black;
}

/* 成功時のテキスト色 */
.text-success {
  color: rgb(var(--v-theme-success)) !important;
}

/* エラー時のテキスト色 */
.text-error {
  color: rgb(var(--v-theme-error)) !important;
}

/* デフォルトのテキスト色 */
.text-grey {
  color: gray !important;
}

/* ツールチップのアクティベーター(透明な重なり要素) */
.validation-tooltip-activator {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none; /* クリックイベントを下の要素に透過 */
}
</style>

テキストフィールド

~/components/Field/Validation.vue

<template>
  <div>
    <!-- フィールド名 -->
    <v-row>
      <v-col class="pb-0">
        <span class="font-weight-medium">{{ field.name }}</span>
      </v-col>
    </v-row>

    <!-- 記入例(存在する場合のみ表示) -->
    <v-row v-if="field.example">
      <v-col class="pt-0 pb-0">
        <span class="text-caption opacity-60">記入例:{{ field.example }}</span>
      </v-col>
    </v-row>

    <!-- 入力フィールド -->
    <v-row>
      <v-col class="pt-0">
        <div class="position-relative h-100 w-100">
          <!-- テキストフィールド -->
          <v-text-field
            :model-value="modelValue"
            variant="outlined"
            color="primary"
            density="compact"
            counter
            :error-messages="field.messages"
            :type="type"
            :append-inner-icon="appendInnerIcon"
            :bg-color="getBackgroundColor"
            :class="{ 'validation-field': true }"
            @update:model-value="$emit('update:modelValue', $event)"
            @blur="(e: FocusEvent) => $emit('blur', e)"
            @focus="(e: FocusEvent) => $emit('focus', e)"
            @input="(e: Event) => $emit('input', (e.target as HTMLInputElement).value)"
            @click:append-inner="(e: MouseEvent) => $emit('click:append-inner', e)"
          />

          <!-- バリデーションツールチップ -->
          <TooltipValidation
            :key="field.id"
            :rules="field.rules"
            :is-show="isShowTooltip"
          />
        </div>
      </v-col>
    </v-row>
  </div>
</template>

<script setup lang="ts">
import type { ValidationField } from "~/validation/types";

interface Props {
  modelValue: string;
  field: ValidationField;
  isShowTooltip: boolean;
  type?: string;
  appendInnerIcon?: string;
}

const props = defineProps<Props>();

defineEmits<{
  (e: "update:modelValue" | "input", value: string): void;
  (e: "focus" | "blur", event: FocusEvent): void;
  (e: "click:append-inner", event: MouseEvent): void;
}>();

/** バリデーション状態に基づいて背景色を計算 */
const getBackgroundColor = computed(() => {
  // フィールドが無効(いずれかのルールに不合格)の場合
  if (props.field.messages.length > 0) {
    return "red-lighten-5";
  }
  // フィールドが有効(すべてのルールに合格)かつ入力値がある場合
  else if (props.field.isValid && props.modelValue) {
    return "green-lighten-5";
  }

  return undefined; // デフォルトの背景色
});
</script>

フォームカード

~/components/Card/CreateUser.vue

<template>
  <v-sheet rounded elevation="2" width="480">
    <v-container class="pt-8 pl-16 pr-16 pb-8">
      <!-- タイトル -->
      <v-row>
        <v-col class="d-flex justify-center">
          <span class="text-h4">ユーザー登録</span>
        </v-col>
      </v-row>

      <!-- ユーザー名 -->
      <FieldValidation
        v-model="formData.username"
        :field="usernameField"
        :is-show-tooltip="tooltips.username"
        @blur="handleBlur('username')"
        @focus="handleFocus('username')"
        @input="validateFieldByTiming('username', 'input')"
      />

      <!-- パスワード -->
      <FieldValidation
        v-model="formData.password"
        :field="passwordField"
        :is-show-tooltip="tooltips.password"
        :type="showPassword ? 'text' : 'password'"
        :append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
        @blur="handleBlur('password')"
        @focus="handleFocus('password')"
        @input="validateFieldByTiming('password', 'input')"
        @click:append-inner="showPassword = !showPassword"
      />

      <!-- パスワード確認 -->
      <FieldValidation
        v-model="formData.passwordConfirm"
        :field="passwordConfirmField"
        :is-show-tooltip="tooltips.passwordConfirm"
        :type="showPasswordConfirm ? 'text' : 'password'"
        :append-inner-icon="showPasswordConfirm ? 'mdi-eye' : 'mdi-eye-off'"
        @blur="handleBlur('passwordConfirm')"
        @focus="handleFocus('passwordConfirm')"
        @input="validateFieldByTiming('passwordConfirm', 'input')"
        @click:append-inner="showPasswordConfirm = !showPasswordConfirm"
      />

      <!-- 送信ボタン -->
      <v-row>
        <v-col class="d-flex justify-center">
          <v-btn
            text="登録する"
            color="primary"
            width="160"
            height="40"
            @click="handleSubmit()"
          />
        </v-col>
      </v-row>
    </v-container>
  </v-sheet>
</template>

<script setup lang="ts">
import type {
  ValidationField,
  ValidationForm,
  ValidationTiming,
  ValidationValue
} from "~/validation/types";
import {
  createRequiredRule,
  createStringsLengthRule,
  createAlphaNumericUnderscoreRule,
  createForbiddenValuesRule,
  createNotContainFieldRule,
  createMatchFieldRule
} from "~/validation/rules";

interface Request {
  username: string;
  password: string;
  passwordConfirm: string;
}

/** フォームデータ */
const formData = reactive<Request>({
  username: "",
  password: "",
  passwordConfirm: ""
});

// パスワード表示状態
const showPassword = ref(false);
const showPasswordConfirm = ref(false);

/** ツールチップの表示状態 */
const tooltips = reactive({
  username: false,
  password: false,
  passwordConfirm: false
});

/** バリデーションヘルパー関数 */
const matchFieldValidate = (fieldValue?: string, value?: string): boolean => {
  if (fieldValue === undefined || value === undefined) {
    return false;
  }
  return fieldValue === value;
};

const notContainFieldValidate = (
  fieldValue?: string,
  value?: string,
  caseSensitive: boolean = false
): boolean => {
  if (fieldValue === undefined || value === undefined || !fieldValue) {
    return true;
  }
  if (value === "") {
    return true;
  }
  if (!caseSensitive) {
    const lowerValue = value.toLowerCase();
    const lowerFieldValue = fieldValue.toLowerCase();
    return !lowerValue.includes(lowerFieldValue);
  }
  return !value.includes(fieldValue);
};

/** ユーザー名のバリデーションフィールド */
const usernameField = reactive<ValidationField>({
  id: "username",
  name: "ユーザー名",
  example: "user_123",
  rules: [
    { ...createRequiredRule() },
    {
      ...createStringsLengthRule(20, 3, "3文字以上20文字以下で入力してください")
    },
    { ...createAlphaNumericUnderscoreRule() },
    {
      ...createForbiddenValuesRule(
        ["admin", "root", "system", "administrator"],
        false,
        "使用できないユーザー名です"
      )
    }
  ],
  messages: [],
  isValid: false
});

/** パスワードのバリデーションフィールド */
const passwordField = reactive<ValidationField>({
  id: "password",
  name: "パスワード",
  rules: [
    { ...createRequiredRule() },
    {
      ...createStringsLengthRule(30, 8, "8文字以上30文字以下で入力してください")
    },
    {
      ...createNotContainFieldRule(
        "ユーザー名",
        formData.username,
        false,
        "パスワードにユーザー名を含めることはできません"
      )
    }
  ],
  messages: [],
  isValid: false
});

/** パスワード確認のバリデーションフィールド */
const passwordConfirmField = reactive<ValidationField>({
  id: "passwordConfirm",
  name: "パスワード確認",
  rules: [
    { ...createRequiredRule() },
    {
      ...createMatchFieldRule(
        "パスワード",
        formData.password,
        "パスワードが一致しません"
      )
    }
  ],
  messages: [],
  isValid: false
});

/** フォーム全体のバリデーション */
const form = reactive<ValidationForm>({
  id: "userRegistration",
  name: "ユーザー登録",
  fields: [usernameField, passwordField, passwordConfirmField],
  isValid: false
});

/** 各フィールドの各タイミングでのエラーメッセージを保持するオブジェクト */
const fieldTimingErrors = reactive({
  username: {
    mount: [] as string[],
    input: [] as string[],
    blur: [] as string[],
    submit: [] as string[]
  },
  password: {
    mount: [] as string[],
    input: [] as string[],
    blur: [] as string[],
    submit: [] as string[]
  },
  passwordConfirm: {
    mount: [] as string[],
    input: [] as string[],
    blur: [] as string[],
    submit: [] as string[]
  }
});

/** フォーム全体の有効性を更新 */
const updateFormValidity = () => {
  form.isValid = form.fields.every((field) => field.isValid);
};

/** 指定されたタイミングに基づいてフィールドのバリデーションを実行 */
const validateFieldByTiming = (fieldId: string, timing: ValidationTiming) => {
  const field = form.fields.find((f) => f.id === fieldId);
  if (!field) return;

  // フィールドの値を取得
  let value: ValidationValue;
  switch (fieldId) {
    case "username":
      value = formData.username;
      break;
    case "password":
      value = formData.password;
      break;
    case "passwordConfirm":
      value = formData.passwordConfirm;
      break;
    default:
      value = "";
  }

  // フォーム全体に関わるルールの更新を先に行う
  field.rules.forEach((rule) => {
    if (rule.scope === "form") {
      if (rule.id === "matchField" && fieldId === "passwordConfirm") {
        rule.validate = (val?: ValidationValue) =>
          matchFieldValidate(formData.password, val as string);
      }

      if (rule.id === "notContainField" && fieldId === "password") {
        rule.validate = (val?: ValidationValue) =>
          notContainFieldValidate(formData.username, val as string, false);
      }
    }
  });

  // すべてのルールの状態を更新(ツールチップ用)
  field.rules.forEach((rule) => {
    // ルールを評価
    rule.isValid = rule.validate(value);

    // ツールチップ表示用の状態を更新
    if (rule.id === "required") {
      // 必須ルールは値の有無で判定
      rule.state = value ? (rule.isValid ? "success" : "error") : "error";
    } else {
      // その他のルールは値があるときのみ判定
      rule.state = value ? (rule.isValid ? "success" : "error") : "default";
    }
  });

  // 指定されたタイミングに該当するルールのみをフィルタリング
  const rulesForTiming = field.rules.filter((rule) =>
    rule.timing.includes(timing)
  );

  // 現在のタイミングのエラーメッセージをクリア
  fieldTimingErrors[fieldId as keyof typeof fieldTimingErrors][timing] = [];

  // タイミングに該当するルールのエラーメッセージを追加
  for (const rule of rulesForTiming) {
    if (!rule.isValid) {
      fieldTimingErrors[fieldId as keyof typeof fieldTimingErrors][timing].push(
        rule.message
      );
    }
  }

  // すべてのタイミングのエラーメッセージを集約
  field.messages = [
    ...new Set([
      ...fieldTimingErrors[fieldId as keyof typeof fieldTimingErrors].mount,
      ...fieldTimingErrors[fieldId as keyof typeof fieldTimingErrors].input,
      ...fieldTimingErrors[fieldId as keyof typeof fieldTimingErrors].blur,
      ...fieldTimingErrors[fieldId as keyof typeof fieldTimingErrors].submit
    ])
  ];

  // フィールドの有効性を更新
  field.isValid = field.rules.every((rule) => rule.isValid);

  // フォーム全体の有効性を更新
  updateFormValidity();
};

// イベントハンドラ
/** フォーカス時のイベントハンドラ */
const handleFocus = (fieldId: string) => {
  // ツールチップを表示
  tooltips[fieldId as keyof typeof tooltips] = true;
};

/** ブラー時のイベントハンドラ */
const handleBlur = (fieldId: string) => {
  // ツールチップを非表示
  tooltips[fieldId as keyof typeof tooltips] = false;
  validateFieldByTiming(fieldId, "blur");
};

/** サブミット時のイベントハンドラ */
const handleSubmit = () => {
  // すべてのフィールドをsubmitタイミングで検証
  form.fields.forEach((field) => validateFieldByTiming(field.id, "submit"));

  // フォームが有効な場合のみ送信
  if (!hasErrors.value) {
    console.log("送信データ:", formData);
    // ここでAPIリクエストなどの処理を行う
  } else {
    console.log("フォームにエラーがあります");
  }
};

// 計算プロパティ
// フォームにエラーがあるかどうか
const hasErrors = computed(() => {
  return form.fields.some((field) => field.messages.length > 0);
});

// ウォッチャー
watch(
  () => formData.username,
  () => {
    // ユーザー名が変更された場合、パスワードも再検証
    if (formData.password) {
      validateFieldByTiming("password", "input");
    }
  }
);
watch(
  () => formData.password,
  () => {
    // パスワードが変更された場合、パスワード確認も再検証
    if (formData.passwordConfirm) {
      validateFieldByTiming("passwordConfirm", "input");
    }
  }
);

// ライフサイクルフック
// 初期バリデーション(マウント時)
onMounted(() => {
  form.fields.forEach((field) => {
    validateFieldByTiming(field.id, "mount");
  });
});
</script>

ページコンポーネント

~/pages/index.vue

<template>
  <v-layout class="bg-grey-lighten-3 fill-height d-flex align-center">
    <v-container>
      <v-row>
        <v-col class="d-flex align-center justify-center">
          <CardCreateUser />
        </v-col>
      </v-row>
    </v-container>
  </v-layout>
</template>