Cross-field validation compares related values, such as a start date and end date or a password and its confirmation. In Angular reactive forms, place a rule that needs sibling controls on their shared FormGroup. A group validator can inspect both values without maintaining a second subscription that manually synchronizes error flags.
The example below checks password confirmation. It assumes a standalone Angular component using ReactiveFormsModule and built-in template control flow.
Write a pure group validator
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const passwordsMatch: ValidatorFn = (
group: AbstractControl
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmation = group.get('confirmation')?.value;
// Required validators handle empty fields separately.
if (!password || !confirmation) return null;
return password === confirmation ? null : { passwordMismatch: true };
};Return null when this rule passes and a named error object when it fails. Keep the function free of network calls and avoid calling setErrors on a child control as a side effect. Mutating child errors can overwrite another validator’s information and makes it harder to reason about when errors disappear.
This validator deliberately leaves emptiness to the required rules. That gives each message a clear responsibility: “enter a confirmation” is different from “the two values do not match.”
Attach it to the form
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { passwordsMatch } from './passwords-match';
@Component({
selector: 'app-password-form',
imports: [ReactiveFormsModule],
templateUrl: './password-form.html'
})
export class PasswordForm {
submitted = false;
readonly form = new FormGroup({
password: new FormControl('', {
nonNullable: true, validators: [Validators.required]
}),
confirmation: new FormControl('', {
nonNullable: true, validators: [Validators.required]
})
}, { validators: passwordsMatch });
submit() {
this.submitted = true;
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// Send through an authenticated service in a real application.
// Do not log password values or show success before server confirmation.
}
}The group owns passwordMismatch, so form.hasError('passwordMismatch') is the relevant check. Neither individual field necessarily has that error. A non-nullable control also resets to its configured initial value rather than null; it does not mean an empty string is valid.
Display an accessible error
<form [formGroup]="form" (ngSubmit)="submit()">
<label for="password">Password</label>
<input id="password" type="password" autocomplete="new-password"
formControlName="password" required />
<label for="confirmation">Confirm password</label>
<input id="confirmation" type="password" autocomplete="new-password"
formControlName="confirmation" required
[attr.aria-invalid]="form.hasError('passwordMismatch') &&
(form.controls.confirmation.touched || submitted) ? 'true' : null"
[attr.aria-describedby]="form.hasError('passwordMismatch') &&
(form.controls.confirmation.touched || submitted) ? 'mismatch' : null" />
@if (form.hasError('passwordMismatch') &&
(form.controls.confirmation.touched || submitted)) {
<p id="mismatch" role="alert">The passwords do not match.</p>
}
@if (submitted && form.hasError('required', 'password')) {
<p role="alert">Enter a password.</p>
}
@if (submitted && form.hasError('required', 'confirmation')) {
<p role="alert">Enter the confirmation.</p>
}
<button type="submit">Continue</button>
</form>Showing a mismatch only after interaction avoids scolding someone before they have started confirming. The error is text associated with the confirmation control, not just a red border. Use unique IDs if several instances of this form can appear together.
Test transitions, not just one invalid value
Start with a matching pair, change confirmation to a different value and then change it back. Assert the group error appears and clears. Also test empty controls: the group mismatch should be absent while each required error is present. Reset the form and verify both values and interaction flags return to the intended state.
At the component level, submit an invalid form and confirm that no save request is sent. Submit a valid form, return a server failure and confirm that the failure is visible without exposing passwords or internal diagnostic details. Client validation must not be the server’s only protection.
Interview answer
“I attach cross-field rules to the shared group so the validator can inspect both controls. It returns a named group error and stays pure. Field-level validators handle their own rules. The template reads the error at the correct level, associates it with the relevant control and shows it at a sensible interaction point. I test error removal and reset behavior as well as the failing case.”
Continue with Angular reactive-forms scenarios, Angular testing MCQs and Angular’s validation guide.