上一篇談過 單層 vs 巢狀表單,用 FormGroup
與 FormArray
對應資料模型。結構搞定後,下一步就是 驗證:
除了欄位級(FormControl
)驗證,實務上更常見 群組級(FormGroup
) 驗證與 條件式 邏輯。
網頁參考:Day22
最簡單的驗證加在 FormControl
上,例如:使用者名稱必填、Email 格式檢查。ng-zorro 以 nz-form
、nz-form-item
、nz-form-control
的 nzErrorTip
呈現錯誤。
驗證規則
// validators.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';
export function idValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value as string | null | undefined;
if (!value) return null;
const regex = /^[A-Z]{1}[0-9]{8}[A-Z]{1}$/;
return regex.test(value) ? null : { idFormat: '身分證需符合「首尾英文字母,中間 8 碼數字」' };
}
// profile.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { CommonModule } from '@angular/common';
import { idValidator } from './validators';
@Component({
standalone: true,
selector: 'app-profile',
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
template: `
<form nz-form nzLayout="vertical" [formGroup]="form">
<nz-form-item>
<nz-form-label nzFor="username" nzRequired>使用者名稱</nz-form-label>
<nz-form-control [nzErrorTip]="'請輸入使用者名稱'">
<input nz-input id="username" formControlName="username" placeholder="請輸入名稱" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label nzFor="nationalId">身分證</nz-form-label>
<nz-form-control [nzErrorTip]="idErrorTpl">
<input nz-input id="nationalId" formControlName="nationalId" placeholder="如 A12345678B" />
<ng-template #idErrorTpl>
<ng-container *ngIf="form.get('nationalId')?.errors?.['idFormat'] as msg">{{ msg }}</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
`
})
export class ProfileComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
username: ['', Validators.required],
nationalId: ['', idValidator]
});
}
重點:把錯誤訊息字串直接從驗證器回傳,UI 不用再組字串,避免誤解。
群組級驗證綁在 FormGroup
的 validators
屬性上,用 group.get('key')
取值。
// validators.ts(節錄)
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const dateRangeValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const start = group.get('startDate')?.value;
const end = group.get('endDate')?.value;
if (!start || !end) return null;
return new Date(start) <= new Date(end) ? null : { dateRange: '結束日期必須晚於或等於開始日期' };
};
// period.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
import { dateRangeValidator } from './validators';
@Component({
standalone: true,
selector: 'app-period',
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzDatePickerModule, NzAlertModule],
template: `
<form nz-form nzLayout="vertical" [formGroup]="form">
<div formGroupName="period">
<nz-form-item>
<nz-form-label nzFor="startDate" nzRequired>開始日期</nz-form-label>
<nz-form-control>
<nz-date-picker id="startDate" formControlName="startDate" nzFormat="YYYY-MM-DD"></nz-date-picker>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label nzFor="endDate" nzRequired>結束日期</nz-form-label>
<nz-form-control>
<nz-date-picker id="endDate" formControlName="endDate" nzFormat="YYYY-MM-DD"></nz-date-picker>
</nz-form-control>
</nz-form-item>
<nz-alert
*ngIf="form.get('period')?.errors?.['dateRange'] as msg"
nzType="error"
[nzMessage]="msg"
nzShowIcon>
</nz-alert>
</div>
</form>
`
})
export class PeriodComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
period: this.fb.group({
startDate: ['2025-09-01', Validators.required],
endDate: ['2025-09-05', Validators.required]
}, { validators: [dateRangeValidator] })
});
}
// validators.ts(節錄)
export const passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const pw = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return pw && confirm && pw !== confirm ? { passwordMismatch: '兩次輸入的密碼不一致' } : null;
};
// account.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
import { passwordMatchValidator } from './validators';
@Component({
standalone: true,
selector: 'app-account',
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule, NzAlertModule],
template: `
<form nz-form nzLayout="vertical" [formGroup]="form">
<div formGroupName="account">
<nz-form-item>
<nz-form-label nzFor="password" nzRequired>密碼</nz-form-label>
<nz-form-control [nzErrorTip]="'請輸入密碼'">
<input nz-input type="password" id="password" formControlName="password" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label nzFor="confirmPassword" nzRequired>確認密碼</nz-form-label>
<nz-form-control [nzErrorTip]="'請再次輸入密碼'">
<input nz-input type="password" id="confirmPassword" formControlName="confirmPassword" />
</nz-form-control>
</nz-form-item>
<nz-alert
*ngIf="form.get('account')?.errors?.['passwordMismatch'] as msg"
nzType="error"
[nzMessage]="msg"
nzShowIcon>
</nz-alert>
</div>
</form>
`
})
export class AccountComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
account: this.fb.group({
password: ['', Validators.required],
confirmPassword: ['', Validators.required]
}, { validators: [passwordMatchValidator] })
});
}
// validators.ts(節錄)
export function oneOfRequired(keys: string[]): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const hasOne = keys.some(k => {
const v = group.get(k)?.value;
return v !== null && v !== undefined && v !== '';
});
return hasOne ? null : { oneOfRequired: 'Email 或手機至少填一個' };
};
}
// contact.component.ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
import { oneOfRequired } from './validators';
@Component({
standalone: true,
selector: 'app-contact',
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule, NzAlertModule],
template: `
<form nz-form nzLayout="vertical" [formGroup]="form">
<div formGroupName="contact">
<nz-form-item>
<nz-form-label nzFor="email">Email</nz-form-label>
<nz-form-control [nzErrorTip]="'Email 格式不正確'">
<input nz-input id="email" formControlName="email" placeholder="example@domain.com" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label nzFor="phone">手機</nz-form-label>
<nz-form-control>
<input nz-input id="phone" formControlName="phone" placeholder="09xxxxxxxx" />
</nz-form-control>
</nz-form-item>
<nz-alert
*ngIf="form.get('contact')?.errors?.['oneOfRequired'] as msg"
nzType="error"
[nzMessage]="msg"
nzShowIcon>
</nz-alert>
</div>
</form>
`
})
export class ContactComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
contact: this.fb.group({
email: ['', Validators.email],
phone: ['']
}, { validators: [oneOfRequired(['email', 'phone'])] })
});
}
需求:題目 A = 1 → 題目 B 必填;A = 2 → 題目 B 不必填。
建議實作:動態切換 B 的 Validators(UX 較佳,B 自己會顯示必填錯誤)。
// conditional-required.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { CommonModule } from '@angular/common';
@Component({
standalone: true,
selector: 'app-conditional-required',
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzSelectModule, NzInputModule, NzAlertModule],
template: `
<form nz-form nzLayout="vertical" [formGroup]="form">
<nz-form-item>
<nz-form-label nzFor="questionA" nzRequired>題目 A</nz-form-label>
<nz-form-control>
<nz-select id="questionA" formControlName="questionA" nzPlaceHolder="請選擇">
<nz-option nzValue="1" nzLabel="選項 1"></nz-option>
<nz-option nzValue="2" nzLabel="選項 2"></nz-option>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label nzFor="questionB" [nzRequired]="isQuestionBRequired">題目 B</nz-form-label>
<nz-form-control [nzErrorTip]="'當題目 A 選 1,題目 B 為必填'">
<input nz-input id="questionB" formControlName="questionB" placeholder="請依題目 A 規則填寫" />
</nz-form-control>
</nz-form-item>
<nz-alert
*ngIf="isQuestionBRequired"
nzType="info"
nzMessage="目前題目 A = 1,因此題目 B 為必填。"
nzShowIcon>
</nz-alert>
</form>
`
})
export class ConditionalRequiredComponent implements OnInit {
private fb = inject(FormBuilder);
form = this.fb.group({
questionA: this.fb.control<string | null>('2', { validators: [Validators.required] }),
questionB: this.fb.control<string | null>('') // 是否 required 由 A 的值決定
});
isQuestionBRequired = false;
ngOnInit(): void {
const aCtrl = this.form.get('questionA') as FormControl<string | null>;
const bCtrl = this.form.get('questionB') as FormControl<string | null>;
const applyRule = (aVal: string | null) => {
const shouldRequire = aVal === '1';
this.isQuestionBRequired = shouldRequire;
if (shouldRequire) {
bCtrl.addValidators(Validators.required);
} else {
bCtrl.removeValidators(Validators.required);
// 清除殘留錯誤,避免 UI 一直紅字
if (bCtrl.hasError('required')) {
const { required, ...rest } = bCtrl.errors ?? {};
bCtrl.setErrors(Object.keys(rest).length ? rest : null);
}
}
bCtrl.updateValueAndValidity({ emitEvent: false });
};
// 初始化
applyRule(aCtrl.value);
// 監聽變化
aCtrl.valueChanges.subscribe(applyRule);
}
}
可替代作法:也能寫成 群組級驗證器 檢查 A 與 B 的組合,但 B 本身不會出現「必填」紅框與星號,UX 稍弱。實務上較推薦「動態切換 B 的 Validators」。
<nz-form-control [nzErrorTip]="...">
。<nz-alert nzType="error">
顯示整組邏輯錯誤。小技巧:提交時可遞迴 markAllAsDirty(),讓所有錯誤一次浮現;條件必填時要記得在移除必填時清掉舊錯誤。
nzErrorTip
直覺呈現。👉 下一篇將進一步示範 FormArray 與動態集合。