The Angular cross-field validator that deletes your other errors

Updated · 8 min read

The short answer

Have the group validator 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.

There is a password-match validator that has been copied across Angular blogs, Stack Overflow answers and starter templates for about eight years. It is on the first page of Google for this query in several forms. It has a bug, and the bug is invisible in every demo anyone has written for it.

The validator in question

The idea is sound. A rule spanning two controls has to live on the 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:

must-match.validator.ts
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;
  };
}

It demos beautifully. Type two different passwords, the message appears; fix it, the message goes. Nothing in a five-minute walkthrough goes wrong, because a five-minute walkthrough puts one validator on the field. Real signup forms put two.

Add minLength and watch

Nothing exotic — the validators any password field has
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);
Node, @angular/forms 17.3.12
confirm.errors  ->  null
confirm.valid   ->  true
form.valid      ->  false

Five characters, a minLength(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.

The form is still invalid, because the password control kept its own minlength error. So the submit button stays disabled, and the field responsible shows nothing at all.

The usual patch is to merge rather than replace — 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

A validator is a pure function from a control to errors. Angular calls it, takes what it returns, and manages the rest. The moment a validator starts writing to other controls it is fighting the framework for ownership of the same field, and the framework is going to win at an unpredictable moment.

passwords-match.validator.ts
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 };
  };
}
Same reproduction, same runtime
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     ->  null

The minlength 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

This is the real reason people reached for 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>
}
One computed, so the condition is written once
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;
});

That stray 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

A cross-field error belongs to no single input, which makes it easy to render as a paragraph that nothing points at. A screen reader user then hears a password field, a confirm field, and a disabled submit button, with the reason absent from the accessibility tree entirely.

  • Point aria-describedby at the message from the second control — the one the user can act on.
  • Bind it to null when there is no error, not false. [attr.x]="null" removes the attribute; false would leave a dangling aria-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 } };
  };
}

The 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

"VAT number is required when the country is in the EU." The common implementation calls 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;
  };
}

Declarative, visible in the form definition, and nothing to unsubscribe from.

The one-line version

If a validator writes instead of returning, it is not a validator — it is a side effect that Angular happens to call on every keystroke, racing the framework for control of a field. Return the error and render it yourself. It is more template code and less debugging.

If you enjoyed this particular flavour of pain, seven more Angular validators that quietly do nothing — including the one where a form is neither valid nor invalid.

For one match rule, write the nine lines above and install nothing. If you end up with a dozen of these and want them behind one error shape, we maintain ngx-smart-validatorsmatchFields 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.