The Angular cross-field validator that deletes your other errors
The short answer
return its error. Never call setErrors() on a child control from inside one: that method replaces the control’s whole error object, so the required and minlength errors Angular set a moment earlier are gone — and the setErrors(null) every tutorial puts in the else branch will happily mark a five-character password valid.
The validator in question
FormGroup, because that is the only object that can see both. The wrinkle is that a group-level error does not render under a field on its own, so the validator writes the error onto the confirm control directly:
export function mustMatch(passwordField: string, confirmField: string): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get(passwordField);
const confirm = group.get(confirmField);
if (!password || !confirm) return null;
if (password.value !== confirm.value) {
confirm.setErrors({ mustMatch: true });
} else {
confirm.setErrors(null);
}
return null;
};
}Add minLength and watch
const form = new FormGroup(
{
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
confirm: new FormControl('', [Validators.required, Validators.minLength(8)]),
},
{ validators: mustMatch('password', 'confirm') },
);
form.patchValue({ password: 'short', confirm: 'short' });
console.log(form.get('confirm')!.errors, form.get('confirm')!.valid);confirm.errors -> null
confirm.valid -> true
form.valid -> falseminLength(8) validator sitting right there on the control, and the control says it is valid. The two values match, so the else branch fires setErrors(null) and erases the minlength error Angular had set a microsecond earlier. setErrors() replaces the error object. It does not merge into it, and it does not care who put the previous errors there.
password control kept its own minlength error. So the submit button stays disabled, and the field responsible shows nothing at all.
confirm.setErrors({ ...confirm.errors, mustMatch: true }) — which fixes this symptom and makes the next one worse: now the else branch either clears errors it never set, or leaves mustMatch on a field that matches perfectly well.
Return the error instead
export function passwordsMatch(passwordField: string, confirmField: string): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get(passwordField);
const confirm = group.get(confirmField);
if (!password || !confirm) return null;
// Nothing to compare against yet. Shouting "they don't match" while the
// user is still on the first field is noise, not feedback.
if (confirm.value === '' || confirm.value == null) return null;
return password.value === confirm.value ? null : { passwordsMatch: true };
};
}confirm.errors when both are "short" -> { minlength: { requiredLength: 8, actualLength: 5 } }
group.errors on a real mismatch -> { passwordsMatch: true }
confirm.errors on a real mismatch -> nullminlength error survives because nothing overwrote it. The mismatch lands on the group, where it is cleared by the same mechanism that set it. No branch of this function mutates anything, which is why it cannot lose an argument with Angular about who owns the field.
Now show it where the user is looking
setErrors in the first place, and it deserves a straight answer rather than a workaround. The error is on the group; the user is looking at an input. Read it from the group and render it next to the input.
<input
id="confirm"
type="password"
formControlName="confirm"
[attr.aria-invalid]="mismatch() ? 'true' : null"
[attr.aria-describedby]="mismatch() ? 'confirm-error' : null"
/>
@if (mismatch()) {
<p id="confirm-error" class="error">The two passwords do not match.</p>
}private readonly status = toSignal(this.form.statusChanges, {
initialValue: this.form.status,
});
protected readonly mismatch = computed(() => {
this.status();
return this.form.hasError('passwordsMatch') && this.form.controls.confirm.touched;
});this.status() is load-bearing. A reactive form publishes validity through an observable, not a signal, so a bare form.hasError() inside a computed has nothing to track and will never recompute — it will read correctly once and then freeze. Reading the signal is what subscribes the computed to the form. The touched check is what stops the message appearing while the user is still typing.
The half everyone skips
Point aria-describedbyat the message from the second control — the one the user can act on.Bind it to nullwhen there is no error, notfalse.[attr.x]="null"removes the attribute;falsewould leave a danglingaria-describedby=""pointing at nothing.Do not reach for role="alert"on a message that re-renders on every keystroke, unless you want to interrupt the user mid-word, every word.
Two variants worth stealing
Start before end
export function dateRange(startField: string, endField: string): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const start = group.get(startField)?.value;
const end = group.get(endField)?.value;
if (!start || !end) return null;
const from = new Date(start);
const to = new Date(end);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return null;
return from.getTime() <= to.getTime() ? null : { dateRange: { startField, endField } };
};
}Number.isNaN guard is the interesting line. An <input type="date"> hands you a string, and the string is half-typed for most of the time the user spends in the field. Without the guard, "2026-0" parses to Invalid Date, every comparison against it is false, and the user is told their dates are backwards while they are still typing the first one. Measured: with the guard, a half-typed date returns null and says nothing.
Required only sometimes
setValidators() from a valueChanges subscription, which works but mutates the form from outside the validation cycle, needs an updateValueAndValidity({ emitEvent: false }) to avoid an infinite loop, and buries the rule where nobody reading the form definition will find it.
export function requiredIf(
targetField: string,
dependentField: string,
predicate: (value: unknown) => boolean,
): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
if (!predicate(group.get(dependentField)?.value)) return null;
const target = group.get(targetField)?.value;
const missing = target === null || target === undefined || target === '';
return missing ? { requiredIf: { targetField, dependentField } } : null;
};
}The one-line version
matchFields is in the free tier and behaves exactly like the version here.
Questions
- Where does a cross-field validator go in Angular?
- On the FormGroup containing both controls, passed as the validators option of the second argument to FormGroup or FormBuilder.group(). A validator attached to a single FormControl cannot see its siblings.
- Why is setErrors() a problem in a cross-field validator?
- setErrors() replaces the control’s entire error object rather than merging into it, so the required and minlength errors Angular set moments earlier are discarded. The setErrors(null) branch is worse: it marks a control valid while it still holds a value its own validators reject.
- How do I show a FormGroup-level error under a specific input?
- Read it from the group with form.hasError(...) and render the message next to the input, gated on that control being touched. Point the input’s aria-describedby at the message element and set aria-invalid on the input.
- Does a FormGroup validator run on every keystroke?
- Yes. It re-runs whenever any child control changes value. If the comparison is expensive, set updateOn to blur on the group rather than trying to debounce inside a synchronous validator.