- Angular
Angularカスタムバリデーターの作り方|非同期・相関チェック
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期連続の黒字決算。