Angular のカスタムバリデーターとは、「コントロールを受け取り、問題がなければ null、問題があればエラーのオブジェクトを返す関数」です。 すぐに結果を返すものは ValidatorFn、API への問い合わせのように時間がかかるものは AsyncValidatorFn(Observable か Promise を返す)として書きます。複数の項目を見比べる相関チェックは、同じ ValidatorFn を FormGroup に付けるだけです。

この記事では、同期・非同期・相関の3種類をコピペで使える1ファイルにまとめ、errors オブジェクトと status(VALID / INVALID / PENDING)が実際にどう変わるかを出力つきで示します。あわせて、組み込み Validators の早見表、timer + switchMap でデバウンスを効かせた重複チェック、updateOn による検証タイミングの違い、Vitest でのテストコードまで、1ページで確認できるようにしました。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular 22.2.1(@angular/forms・@angular/common)/ RxJS 7.8.2 / TypeScript 6.0.3 / Vitest 5.0.3 + jsdom 30.1.1。ng new で作ったプロジェクトで ng test を実行した出力を載せています。

カスタムバリデーターの早見表

種類 型 返すもの 付ける場所 主な用途
同期 ValidatorFn ValidationErrors | null FormControl の validators 使えない語、形式、範囲など、その場で判定できるもの
相関(クロスフィールド) ValidatorFn ValidationErrors | null FormGroup の validators パスワードの一致、開始日と終了日の前後など
非同期 AsyncValidatorFn Observable または Promise(中身は ValidationErrors | null) FormControl の asyncValidators サーバーへの重複チェックなど
やりたいこと 書き方
引数つきのバリデーターを作る function forbiddenWord(word: string): ValidatorFn { return (control) => ... }
引数なしで作る const notBlank: ValidatorFn = (control) => ...
コントロールに付ける new FormControl('', { validators: [...], asyncValidators: [...] })
グループに付ける fb.group({ ... }, { validators: [matchFields('password', 'confirm')] })
エラーの有無を調べる control.hasError('forbiddenWord')
エラーの中身を取り出す control.getError('forbiddenWord')
検証中かを調べる control.pending(status は PENDING)
検証のタイミングを変える updateOn: 'blur' または 'submit'
あとからバリデーターを足す control.addValidators(fn) のあと control.updateValueAndValidity()
サーバーから返ったエラーを付ける control.setErrors({ serverError: '...' })

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularカスタムバリデーターの作り方|非同期・相関チェック」

バリデーターの約束ごと:null かエラーのオブジェクトを返す

カスタムバリデーターで覚えることは、実質1つだけです。

  • 問題がなければ null を返す
  • 問題があれば { エラーのキー: 詳細 } の形のオブジェクトを返す

返したオブジェクトは、そのまま control.errors に入ります。複数のバリデーターがエラーを返した場合は、1つのオブジェクトにまとめられます。キーの名前は自由に決められるので、テンプレートで出し分けるときに読みやすい名前(forbiddenWord、nameTaken など)にしておくと扱いやすくなります。詳細の部分には、メッセージに埋め込みたい値(上限、実際の値など)を入れておくのがコツです。

コピペ用:同期・相関・非同期を1ファイルにまとめる

次の validators.ts に、この記事で使う5つのバリデーターをまとめました。Angular 22.2.1 でそのまま動きます。

import { AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';

/** 同期:指定した語を含んでいたらエラー */
export function forbiddenWord(word: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = String(control.value ?? '');
    return value.includes(word) ? { forbiddenWord: { word, actual: value } } : null;
  };
}

/** 同期:前後の空白だけの入力をエラーにする(引数なしの形) */
export const notBlank: ValidatorFn = (control) =>
  String(control.value ?? '').trim().length === 0 ? { notBlank: true } : null;

/** 相関:2つのコントロールの値が一致しなければエラー(FormGroup に付ける) */
export function matchFields(a: string, b: string): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const x = group.get(a)?.value;
    const y = group.get(b)?.value;
    if (!x || !y) return null;
    return x === y ? null : { fieldsMismatch: { fields: [a, b] } };
  };
}

/** 相関:開始日が終了日より後ならエラー */
export function dateOrder(start: string, end: string): ValidatorFn {
  return (group) => {
    const s = group.get(start)?.value;
    const e = group.get(end)?.value;
    if (!s || !e) return null;
    return s <= e ? null : { dateOrder: { start: s, end: e } };
  };
}

/** 非同期:入力が止まってから ms ミリ秒後に重複チェックを呼ぶ */
export function uniqueName(
  isTaken: (name: string) => Observable<boolean>,
  ms = 300,
): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) return of(null);
    return timer(ms).pipe(
      switchMap(() => isTaken(control.value)),
      map((taken) => (taken ? { nameTaken: { name: control.value } } : null)),
      catchError(() => of({ checkUnavailable: true })),
    );
  };
}

以降のセクションで、種類ごとに使い方と実際の出力を見ていきます。

同期バリデーター:ValidatorFn の使い方と出力

forbiddenWord('admin') と notBlank を、組み込みの Validators.required と並べて付けた例です。

const c = new FormControl('', {
  nonNullable: true,
  validators: [Validators.required, notBlank, forbiddenWord('admin')],
});

値を順に変えたときの status と errors は次のとおりでした。

"" -> status=INVALID errors={"required":true,"notBlank":true}
"   " -> status=INVALID errors={"notBlank":true}
"admin01" -> status=INVALID errors={"forbiddenWord":{"word":"admin","actual":"admin01"}}
"taro" -> status=VALID errors=null

ここから3つのことがわかります。

  • 複数のバリデーターの結果は1つの errors にまとまる(空文字では required と notBlank の両方が入っている)。
  • Validators.required は空白だけの入力(" ")を通す。空白だけを入力として認めたくないときは、notBlank のような小さなバリデーターを足すと補えます。
  • 問題がなくなると errors は空のオブジェクトではなく null になる。テンプレートでは control.errors?.['キー'] か control.hasError('キー') で読むと安全です。

引数つきと引数なしの書き分け

形 書き方 付け方 向いている場面
引数つき(関数を返す関数) function forbiddenWord(word: string): ValidatorFn forbiddenWord('admin') 語・上限・パターンなどを使う側で決めたい
引数なし(関数そのもの) const notBlank: ValidatorFn = ... notBlank ルールが固定

Validators.required(かっこなし)と Validators.minLength(8)(かっこあり)の違いも、この2つの形の違いです。

相関チェック:FormGroup に付けて複数項目を見比べる

パスワードと確認用パスワードの一致、開始日と終了日の前後関係のように、2つ以上の項目を見比べる検証は、親の FormGroup に付けます。 バリデーターには FormGroup が渡されるので、group.get('項目名') で子の値を読みます。

const g = new FormGroup(
  {
    password: new FormControl('abc12345', Validators.required),
    confirm: new FormControl('abc1234', Validators.required),
    start: new FormControl('2026-10-10'),
    end: new FormControl('2026-10-01'),
  },
  { validators: [matchFields('password', 'confirm'), dateOrder('start', 'end')] },
);
group.status=INVALID group.errors={"fieldsMismatch":{"fields":["password","confirm"]},"dateOrder":{"start":"2026-10-10","end":"2026-10-01"}}
confirm.status=VALID confirm.errors=null
修正後 group.status=VALID group.errors=null

大事なのは2行目です。エラーはグループに付き、子の confirm は VALID のままです。そのため、テンプレートでのエラー表示は confirm.hasError(...) ではなく、form.hasError('fieldsMismatch') のようにグループ側を見ます。入力欄に赤枠を付けたいときも、form.hasError('fieldsMismatch') && form.controls.confirm.touched のような条件でクラスを切り替えます。

matchFields と dateOrder では、どちらかが空のうちは null を返すようにしています。「未入力」の案内は各項目の required に任せ、相関チェックは「両方入ったあと」だけを担当させると、メッセージが重ならずすっきりします。

非同期バリデーター:AsyncValidatorFn と PENDING

ユーザー名の重複チェックのように、サーバーに問い合わせないと結果がわからない検証は AsyncValidatorFn で書きます。 返すのは「ValidationErrors | null を流して完了する Observable(または Promise)」です。付ける場所は validators ではなく asyncValidators です。

const c = new FormControl('', {
  nonNullable: true,
  validators: [Validators.required, Validators.minLength(3)],
  asyncValidators: [uniqueName(isTaken, 300)],
});

isTaken は「名前を受け取り、使用済みなら true を流す Observable を返す関数」です。ここでは呼ばれた名前を calls に記録し、200 ミリ秒後に結果を返すものを渡して、時間を進めながら状態を記録しました(taro は使用済み、jiro は空いている設定)。

初期: status=INVALID errors={"required":true}
"ta": status=INVALID errors={"minlength":{"requiredLength":3,"actualLength":2}} calls=[]
"taro" 直後: status=PENDING errors=null calls=[]
299ms後: status=PENDING calls=[]
300ms後: status=PENDING calls=["taro"]
500ms後: status=INVALID errors={"nameTaken":{"name":"taro"}} calls=["taro"]
"jiro" 500ms後: status=VALID errors=null calls=["taro","jiro"]
statusChanges: ["INVALID","PENDING","PENDING","INVALID","PENDING","VALID"]

出力からわかる非同期バリデーターの動き

観察 意味
"ta" では calls=[] 同期バリデーターがエラーのあいだ、非同期バリデーターは呼ばれない。形式が整ってから問い合わせるので、通信の回数を抑えられる
"taro" 直後は PENDING、errors=null 結果待ちのあいだは valid でも invalid でもない第3の状態になる
tar → taro と続けて入力しても calls は ["taro"] だけ 値が変わると前回の検証は取り消され、タイマーが最初からやり直しになる
500ms 後に INVALID と nameTaken Observable が完了した時点で結果が errors に入る

timer + switchMap がデバウンスになる理由

uniqueName の中心は次の3行です。

return timer(ms).pipe(
  switchMap(() => isTaken(control.value)),
  map((taken) => (taken ? { nameTaken: { name: control.value } } : null)),

Angular は、値が変わって検証をやり直すとき、進行中だった非同期バリデーターの購読を解除します。 timer(300) が鳴る前に次の入力が来れば、タイマーごと取り消されるため、isTaken は呼ばれません。結果として、「入力が 300 ミリ秒止まったら1回だけ問い合わせる」というデバウンスになります。上の出力で、299ms の時点ではまだ呼ばれず、300ms でちょうど1回呼ばれていることが確認できます。

問い合わせがエラーになったときに備える

catchError で、通信エラーを「検証結果」に変換しておきます。

通信エラー: status=INVALID errors={"checkUnavailable":true}
同期的に完了する非同期: status=VALID

エラーを { checkUnavailable: true } に変換しておけば、PENDING のまま止まることがなく、利用者には「いまは確認できません」と案内できます。確認できないときに通す方針にするなら、catchError(() => of(null)) に変えるだけです。どちらにするかは、サーバー側でも同じチェックをしているかどうかで決めるとよいでしょう。

完成フォーム:3種類を組み合わせた登録フォーム

ここまでのバリデーターを、リアクティブフォームの登録画面にまとめます。重複チェックは HttpClient で /api/users/exists?name=... を呼ぶ想定です。

API を呼ぶサービス(user-api.ts)

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class UserApi {
  private readonly http = inject(HttpClient);

  /** GET /api/users/exists?name=taro → { "exists": true } */
  isTaken(name: string): Observable<boolean> {
    return this.http
      .get<{ exists: boolean }>('/api/users/exists', { params: { name } })
      .pipe(map((res) => res.exists));
  }
}

フォームのコンポーネント(signup-form.ts)

import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { UserApi } from './user-api';
import { forbiddenWord, matchFields, uniqueName } from './validators';

@Component({
  selector: 'app-signup-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <label for="userName">ユーザー名</label>
      <input id="userName" formControlName="userName" />
      @if (userName.pending) {
        <p id="userName-pending">確認中です…</p>
      }
      @if (userName.invalid && (userName.touched || userName.dirty)) {
        <p class="error" id="userName-error">
          @if (userName.hasError('required')) {
            ユーザー名を入力してください。
          } @else if (userName.hasError('minlength')) {
            {{ userName.getError('minlength').requiredLength }}文字以上で入力してください。
          } @else if (userName.hasError('forbiddenWord')) {
            「{{ userName.getError('forbiddenWord').word }}」を含む名前は使えません。
          } @else if (userName.hasError('nameTaken')) {
            「{{ userName.getError('nameTaken').name }}」はすでに使われています。
          } @else if (userName.hasError('checkUnavailable')) {
            いまは確認できません。少し待ってからもう一度お試しください。
          }
        </p>
      }

      <label for="password">パスワード</label>
      <input id="password" type="password" formControlName="password" />
      <label for="confirm">パスワード(確認)</label>
      <input id="confirm" type="password" formControlName="confirm" />
      @if (form.hasError('fieldsMismatch') && form.controls.confirm.touched) {
        <p class="error" id="confirm-error">パスワードが一致していません。</p>
      }

      <button type="submit" [disabled]="form.pending">登録する</button>
      <p id="status">status: {{ form.status }}</p>
    </form>
    @if (result(); as r) {
      <p id="result">{{ r }}</p>
    }
  `,
})
export class SignupForm {
  private readonly fb = inject(FormBuilder);
  private readonly api = inject(UserApi);
  protected readonly result = signal('');

  readonly form = this.fb.nonNullable.group(
    {
      userName: [
        '',
        {
          validators: [Validators.required, Validators.minLength(3), forbiddenWord('admin')],
          asyncValidators: [uniqueName((name) => this.api.isTaken(name), 300)],
        },
      ],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirm: ['', [Validators.required]],
    },
    { validators: [matchFields('password', 'confirm')] },
  );

  protected get userName() {
    return this.form.controls.userName;
  }

  protected onSubmit(): void {
    if (this.form.invalid || this.form.pending) {
      this.form.markAllAsTouched();
      return;
    }
    this.result.set(`登録内容: ${JSON.stringify(this.form.getRawValue())}`);
  }
}

動かした結果

テスト用の HTTP(HttpTestingController)で応答を返しながら、入力→応答→送信の順に操作した記録です。

入力直後: status: PENDING / 確認中です…
リクエスト: GET /api/users/exists?name=taro
応答後: status: INVALID / 「taro」はすでに使われています。
不一致: status: INVALID / パスワードが一致していません。
一致: status: VALID
登録内容: {"userName":"jiro","password":"abc12345","confirm":"abc12345"}
503: status: INVALID / いまは確認できません。少し待ってからもう一度お試しください。
admin2: status: INVALID / 「admin」を含む名前は使えません。
  • tar → taro と続けて入力しても、リクエストは name=taro の1回だけでした。
  • 応答が { "exists": true } なら nameTaken、503 なら checkUnavailable のメッセージに切り替わっています。
  • admin2 は同期バリデーターで止まるため、API は呼ばれません。

このフォームで押さえているポイント

ポイント コードの箇所 理由
検証中の表示 @if (userName.pending) 結果待ちのあいだ、何が起きているかを伝える
送信ボタンの制御 [disabled]="form.pending" 検証の結果が出る前に送信されるのを防ぐ
送信時の確認 if (this.form.invalid || this.form.pending) PENDING のときは invalid が false なので、両方を見る
未入力のまま送信 this.form.markAllAsTouched() 触っていない欄のエラーもまとめて表示する
相関エラーの表示 form.hasError('fieldsMismatch') エラーはグループに付くため

組み込み Validators の早見表

自作する前に、組み込みで足りるかを確認しましょう。Validators クラスの各関数に値を渡したときの戻り値を一覧にしました(右端の列が実際の出力です)。

バリデーター 渡した値 戻り値(errors に入る内容)
Validators.required "" {"required":true}
Validators.required " "(空白のみ) null
Validators.required 0 null
Validators.required [](空の配列) {"required":true}
Validators.requiredTrue false {"required":true}
Validators.min(18) 15 {"min":{"min":18,"actual":15}}
Validators.min(18) "" null
Validators.max(120) 130 {"max":{"max":120,"actual":130}}
Validators.minLength(8) "abc" {"minlength":{"requiredLength":8,"actualLength":3}}
Validators.minLength(8) "" null
Validators.maxLength(5) "abcdefg" {"maxlength":{"requiredLength":5,"actualLength":7}}
Validators.pattern(/^[a-z]+$/) "ABC" {"pattern":{"requiredPattern":"/^[a-z]+$/","actualValue":"ABC"}}
Validators.pattern('[a-z]+') "ABC" {"pattern":{"requiredPattern":"^[a-z]+$","actualValue":"ABC"}}
Validators.email "taro@" {"email":true}
Validators.email "" null
Validators.nullValidator "x" null
Validators.compose([required, minLength(3)]) "" {"required":true}
Validators.compose([minLength(3), pattern(/^\d+$/)]) "ab" {"minlength":{...},"pattern":{...}}(両方入る)

読み取っておきたい点は次のとおりです。

  • 関数名は minLength、エラーのキーは minlength(すべて小文字)。hasError('minLength') と書くと一致しないので、キーは上の表からコピーするのが確実です。
  • requiredTrue のキーは required。同意チェックボックスに使います。
  • min・minLength・email などは空の値を通す。必須にするなら required を併記します。
  • pattern は文字列で渡すと前後に ^ と $ が補われる。正規表現オブジェクトで渡すとそのまま使われます。
  • required は数値の 0 を「入力あり」と扱い、空の配列は「入力なし」と扱う。

status の4つの値と、フォーム全体への伝わり方

status 意味 対応するプロパティ
VALID すべての検証を通った valid が true
INVALID 検証エラーがある invalid が true
PENDING 非同期の検証が進行中 pending が true(valid も invalid も false)
DISABLED 無効化されていて、検証の対象外 disabled が true(valid も invalid も false)

required の付いた空の欄を disable() したところ、その欄は DISABLED、親のグループは VALID になり、group.value からその欄が外れました(getRawValue() には残ります)。条件によって不要になる欄は、バリデーターを外す代わりに disable() するという方法も選べます。

子のコントロールが PENDING のあいだは、親のフォームも PENDING になります。完成フォームの記録でも、ユーザー名の確認中は status: PENDING と表示されていました。送信の条件を form.valid で書けば、PENDING のあいだは自動的に送信されません。 !form.invalid と書くと PENDING を通してしまうので、form.valid を使うか、完成フォームのように pending も合わせて確認します。

updateOn の違い:検証のタイミングを変える

既定では、1文字入力するたびに値が更新され、検証が走ります。updateOn を指定すると、このタイミングを変えられます。

@Component({
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submitted = true">
      <input id="a" formControlName="a" />
      <input id="b" formControlName="b" />
      <input id="c" formControlName="c" />
      <button type="submit">送信</button>
    </form>`,
})
class UpdateOnDemo {
  private fb = inject(FormBuilder);
  submitted = false;
  form = this.fb.group({
    a: ['', { validators: [Validators.required] }],
    b: ['', { validators: [Validators.required], updateOn: 'blur' as const }],
    c: ['', { validators: [Validators.required], updateOn: 'submit' as const }],
  });
}

3つの入力欄すべてに x を入力し、フォーカスを外し、送信ボタンを押したときの記録です。

初期: value={"a":"","b":"","c":""} status={"a":"INVALID","b":"INVALID","c":"INVALID"}
入力直後: value={"a":"x","b":"","c":""} status={"a":"VALID","b":"INVALID","c":"INVALID"}
blur後: value={"a":"x","b":"x","c":""} status={"a":"VALID","b":"VALID","c":"INVALID"}
送信後: value={"a":"x","b":"x","c":"x"} status={"a":"VALID","b":"VALID","c":"VALID"}
updateOn 値と検証が更新されるタイミング 向いている場面
'change'(既定) 入力のたび 入力しながら結果を見せたい欄
'blur' フォーカスを外したとき 非同期バリデーターや重い検証のある欄
'submit' 送信したとき 送信時にまとめて確認すれば足りるフォーム

updateOn は「検証だけ」でなく「値の更新」も遅らせます。 blur の欄は、フォーカスを外すまで form.value に入力内容が入りません。入力中の値を別の場所に表示したい画面では、既定の change のままにして、非同期バリデーター側のデバウンスで回数を抑える方法が合います。

デバウンスと updateOn の使い分け

方法 問い合わせの回数 利用者から見た動き
timer + switchMap(この記事の uniqueName) 入力が止まるたびに1回 入力の途中でも、手を止めれば結果が出る
updateOn: 'blur' フォーカスを外すたびに1回 次の欄に移ったときに結果が出る

errors を扱う API:hasError・getError・setErrors

errors を直接読む代わりに使える API と、あとからバリデーターやエラーを足す方法です。

const g = new FormGroup({ name: new FormControl(''), note: new FormControl('') });
g.controls.name.addValidators(Validators.required);
// → この時点ではまだ再検証されていない
g.controls.name.updateValueAndValidity();
g.controls.note.setErrors({ serverError: '登録済みです' });
g.controls.note.setValue('x');
addValidators 直後: VALID
updateValueAndValidity 後: INVALID hasValidator=true
g.hasError('required', 'name')=true g.get('name')?.getError('required')=true
setErrors: {"serverError":"登録済みです"} group.status=INVALID
setValue 後: null
API 用途 出力から読み取れること
addValidators(fn) / removeValidators(fn) 条件に応じてバリデーターを足す・外す 足しただけでは status は変わらない。updateValueAndValidity() を呼んで再検証する
hasValidator(fn) そのバリデーターが付いているか 同じ関数の参照を渡して確認する
hasError('キー', 'パス') 子のエラーを親から調べる g.hasError('required', 'name') のように書ける
getError('キー') エラーの詳細を取り出す getError('minlength').requiredLength のように使う
setErrors({...}) 送信後にサーバーから返ったエラーを付ける 親の status も INVALID になる。次に値が変わると再検証されて消える

forbiddenWord('admin') のような引数つきバリデーターは、呼ぶたびに別の関数が作られます。あとで removeValidators や hasValidator を使う予定があるなら、const noAdmin = forbiddenWord('admin'); のように変数に入れておき、同じ参照を使い回します。

テンプレート駆動フォームで使う:ディレクティブにする

ngModel を使うテンプレート駆動フォームでは、バリデーターを属性として書けるように、NG_VALIDATORS に登録したディレクティブで包みます。中身は先ほどの forbiddenWord をそのまま呼ぶだけです。

import { Directive, input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
import { forbiddenWord } from './validators';

@Directive({
  selector: '[appForbiddenWord]',
  providers: [{ provide: NG_VALIDATORS, useExisting: ForbiddenWordDirective, multi: true }],
})
export class ForbiddenWordDirective implements Validator {
  readonly appForbiddenWord = input.required<string>();

  validate(control: AbstractControl): ValidationErrors | null {
    return forbiddenWord(this.appForbiddenWord())(control);
  }
}
@Component({
  imports: [FormsModule, ForbiddenWordDirective],
  template: `<form #f="ngForm"><input id="n" name="n" [(ngModel)]="n" required appForbiddenWord="admin" /></form>`,
})
class TemplateDemo { n = ''; }

admin01 と入力したときの errors は、リアクティブフォームのときと同じ形になりました。

ディレクティブ: {"forbiddenWord":{"word":"admin","actual":"admin01"}}

ロジックは関数に置き、ディレクティブは薄い包みにすると、リアクティブフォームとテンプレート駆動フォームの両方で同じバリデーターを使えます。

Vitest でバリデーターをテストする

バリデーターはただの関数なので、画面を描画せずにテストできます。 Angular 22 の ng new で入る Vitest で、次のテストがそのまま動きます。

import { FormControl, FormGroup } from '@angular/forms';
import { of, throwError } from 'rxjs';
import { forbiddenWord, matchFields, uniqueName } from './validators';

describe('forbiddenWord', () => {
  const validate = forbiddenWord('admin');

  it('語を含むとエラーを返す', () => {
    expect(validate(new FormControl('admin01'))).toEqual({
      forbiddenWord: { word: 'admin', actual: 'admin01' },
    });
  });

  it.each(['taro', '', null])('%s は通す', (value) => {
    expect(validate(new FormControl(value))).toBeNull();
  });
});

describe('matchFields', () => {
  const group = (password: string, confirm: string) =>
    new FormGroup(
      { password: new FormControl(password), confirm: new FormControl(confirm) },
      { validators: matchFields('password', 'confirm') },
    );

  it('一致しないとグループがエラーになる', () => {
    expect(group('abc12345', 'abc1234').errors).toEqual({
      fieldsMismatch: { fields: ['password', 'confirm'] },
    });
  });

  it('一致すれば null', () => {
    expect(group('abc12345', 'abc12345').errors).toBeNull();
  });
});

describe('uniqueName', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it('連続で入力しても確認は最後の1回だけ', () => {
    const isTaken = vi.fn((name: string) => of(name === 'taro'));
    const control = new FormControl('', { asyncValidators: uniqueName(isTaken, 300) });

    control.setValue('t');
    vi.advanceTimersByTime(100);
    control.setValue('ta');
    vi.advanceTimersByTime(100);
    control.setValue('taro');
    expect(control.status).toBe('PENDING');
    expect(isTaken).not.toHaveBeenCalled();

    vi.advanceTimersByTime(300);
    expect(isTaken).toHaveBeenCalledTimes(1);
    expect(isTaken).toHaveBeenCalledWith('taro');
    expect(control.status).toBe('INVALID');
    expect(control.errors).toEqual({ nameTaken: { name: 'taro' } });
  });

  it('確認用の API がエラーでも PENDING のまま残らない', () => {
    const control = new FormControl('jiro', {
      asyncValidators: uniqueName(() => throwError(() => new Error('down')), 300),
    });
    vi.advanceTimersByTime(300);
    expect(control.status).toBe('INVALID');
    expect(control.hasError('checkUnavailable')).toBe(true);
  });
});

ng test --watch=false --reporters=verbose の出力です。

 ✓  demo  src/app/cv/validators.unit.spec.ts > forbiddenWord > 語を含むとエラーを返す 5ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > forbiddenWord > taro は通す 1ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > forbiddenWord >  は通す 0ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > forbiddenWord > null は通す 0ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > matchFields > 一致しないとグループがエラーになる 1ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > matchFields > 一致すれば null 1ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > uniqueName > 連続で入力しても確認は最後の1回だけ 8ms
 ✓  demo  src/app/cv/validators.unit.spec.ts > uniqueName > 確認用の API がエラーでも PENDING のまま残らない 2ms

 Test Files  1 passed (1)
      Tests  8 passed (8)
テストしたいもの 書き方のポイント
同期バリデーター validate(new FormControl(値)) の戻り値を toEqual / toBeNull で確認
相関バリデーター 小さな FormGroup を作り、group.errors を確認
非同期バリデーターのデバウンス vi.useFakeTimers() で時間を止め、vi.advanceTimersByTime(300) で進める
API が呼ばれた回数 vi.fn() で作った関数を渡し、toHaveBeenCalledTimes(1) で確認
API のエラー時 throwError(() => new Error(...)) を返す関数を渡す

つまずきやすい点と直し方

起きること 原因 直し方
hasError('minLength') が常に false エラーのキーは minlength(小文字) キーを早見表のとおりに書く
相関チェックのエラーが confirm.errors に出ない エラーはバリデーターを付けた FormGroup に入る form.hasError('fieldsMismatch') で読む
非同期バリデーターが呼ばれない 同期バリデーターがエラーのあいだは呼ばれない 同期側を通る値で確認する(仕様どおりの動き)
正しい値でも常に INVALID になり、errors が {"source":{...}} のような見慣れない形になる 非同期バリデーターを validators の配列に入れている(返した Observable 自体がエラーとして扱われる) asyncValidators に入れる
addValidators したのに状態が変わらない 再検証が走っていない updateValueAndValidity() を呼ぶ
検証中に送信できてしまう !form.invalid で判定している form.valid、または form.pending も確認する
空白だけの入力が required を通る Validators.required は空白のみの文字列を通す notBlank のようなバリデーターを足す

よくある質問

Q. カスタムバリデーターはどこに置くのがよいですか。 A. この記事の validators.ts のように、コンポーネントから独立したファイルに関数として置くのがおすすめです。複数のフォームから import でき、テストも関数単体で書けます。

Q. 非同期バリデーターの中でサービスを使うにはどうしますか。 A. この記事の uniqueName のように「問い合わせ用の関数」を引数で受け取る形にすると、バリデーター自体は依存を持たずに済みます。コンポーネント側で uniqueName((name) => this.api.isTaken(name), 300) と渡します。テストでは vi.fn() に差し替えるだけです。

Q. 相関チェックのエラーを、特定の入力欄のエラーとして扱いたいときは。 A. グループのバリデーターの中で group.get('confirm')?.setErrors(...) を呼ぶ方法もありますが、その欄自身の検証結果と混ざりやすくなります。まずはグループの errors をテンプレートで読む形(form.hasError('fieldsMismatch'))を試し、表示位置は @if を置く場所で調整するのが扱いやすい方法です。

Q. Promise を返す非同期バリデーターでもよいですか。 A. はい。AsyncValidatorFn は Promise と Observable のどちらも返せます。async (c) => (c.value === 'taro' ? { nameTaken: true } : null) を asyncValidators に付けたところ、直後は PENDING、解決後に INVALID と {"nameTaken":true} になりました。この記事で Observable を使っているのは、値が変わったときに購読の解除でタイマーごと取り消せて、デバウンスを短く書けるためです。

まとめ

  • バリデーターは「問題なければ null、あればエラーのオブジェクト」を返す関数。同期は ValidatorFn、非同期は AsyncValidatorFn。
  • 相関チェックは FormGroup に付ける。エラーはグループに入るので、form.hasError('キー') で読む。
  • 非同期バリデーターは asyncValidators に入れる。同期バリデーターが通ったあとにだけ呼ばれ、結果待ちのあいだは PENDING になる。
  • timer(ms) + switchMap でデバウンス。値が変わると前回の購読が解除されるので、最後の入力の分だけ問い合わせる。
  • 通信エラーは catchError で検証結果に変換しておくと、PENDING のまま止まらない。
  • updateOn: 'blur' / 'submit' は、検証だけでなく値の更新も遅らせる。
  • バリデーターは関数なので Vitest で単体テストしやすい。時間は vi.useFakeTimers() で進める。

参考

  • Angular 公式ドキュメント「フォーム入力の検証」 https://angular.jp/guide/forms/form-validation
  • Angular 公式ドキュメント「Forms in Angular」 https://angular.dev/guide/forms

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularカスタムバリデーターの作り方|非同期・相関チェック」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?