Reactive form validation uses ValidatorFn functions applied when building the FormControl. Multiple validators are supplied as an array. Cross-field validation applies at the FormGroup level — a group-level validator can read multiple controls and return errors on the group. Error display in templates uses hasError() for clean conditional checks. The updateOn option defers validation to blur or submit events, reducing distracting real-time validation for complex fields like passwords.
Validators and Cross-Field Validation
import { FormBuilder, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
// ── Cross-field validator — password confirmation ──────────────────────────
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
if (!password || !confirmPassword) return null; // only validate when both have values
return password === confirmPassword
? null // valid — return null
: { passwordMismatch: true }; // invalid — return error object
}
// ── Registration form with cross-field validation ─────────────────────────
@Component({ standalone: true, imports: [ReactiveFormsModule], template: `...` })
export class RegisterComponent {
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
displayName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
// updateOn: 'blur' — only validates when the field loses focus
password: ['', {
validators: [Validators.required, Validators.minLength(8)],
updateOn: 'blur',
}],
confirmPassword: ['', Validators.required],
}, {
validators: passwordMatchValidator, // group-level validator
});
// ── Typed error access in template ────────────────────────────────────
// form.controls.email.hasError('required') → boolean
// form.controls.email.hasError('email') → boolean
// form.hasError('passwordMismatch') → boolean (group-level)
// form.controls.password.errors?.['minlength'].requiredLength → number
}
// ── Template error display patterns ──────────────────────────────────────
// <div formGroupName="..."> is used for nested FormGroups in templates
//
// <input formControlName="email">
// @if (form.controls.email.hasError('required') && form.controls.email.touched) {
// <span>Email required</span>
// }
// @if (form.controls.email.hasError('email') && form.controls.email.touched) {
// <span>Invalid email format</span>
// }
// Group-level error:
// @if (form.hasError('passwordMismatch')) {
// <span>Passwords do not match</span>
// }
// ── Reusable error display component ─────────────────────────────────────
@Component({
selector: 'app-field-error',
standalone: true,
template: `
@if (control?.invalid && control?.touched) {
@if (control?.hasError('required')) { <span>This field is required.</span> }
@if (control?.hasError('email')) { <span>Enter a valid email.</span> }
@if (control?.hasError('minlength')) {
<span>Minimum {{ control?.errors?.['minlength'].requiredLength }} characters.</span>
}
@if (control?.hasError('pattern')) { <span>{{ patternMessage }}</span> }
@if (control?.hasError('serverError')) {
<span>{{ control?.errors?.['serverError'] }}</span>
}
}
`,
})
export class FieldErrorComponent {
@Input() control: AbstractControl | null = null;
@Input() patternMessage = 'Invalid format.';
}
AbstractControl representing the group — call group.get('controlName') to access individual controls. Group-level errors are accessed via form.errors (not form.controls.X.errors). To display a group-level error in the template, check form.hasError('passwordMismatch') at the form level, not inside a specific field’s error block.updateOn: 'blur' to controls where real-time validation is jarring — password fields where the user is still deciding their password, complex pattern fields, and fields with expensive async validators. Validation still shows on submit (markAllAsTouched()) and on blur. The default updateOn: 'change' is appropriate for most fields where instant feedback is helpful (like character count remaining). Set updateOn at the FormGroup level to apply it to all controls at once.valueChanges with debounceTime() instead of using a group-level async validator directly.Common Mistakes
Mistake 1 — Applying cross-field validator to a control instead of the group
❌ Wrong — fb.control('', passwordMatchValidator); validator has access to only this one control, not the group.
✅ Correct — apply cross-field validators to the FormGroup: fb.group({...}, { validators: passwordMatchValidator }).
Mistake 2 — Not returning null for valid state (validator always marks invalid)
❌ Wrong — validator returns {} when valid; an empty object is truthy, marking the control invalid.
✅ Correct — return null when validation passes; return { errorKey: true } when it fails.