Live demo
This form is prefilled with two values that disagree. form.errors carries the group-level result from the moment the group was constructed — before any rendering. confirm.errors carries match's result too, because formControlName revalidated the control when it bound; construct the same group without rendering it and only the group-level one is there.
Usage
import { FormControl, FormGroup } from '@angular/forms';
import { WrValidators } from 'ngwr/validators';
const form = new FormGroup(
{
password: new FormControl(''),
confirm: new FormControl(''),
},
{ validators: [WrValidators.matchFields('password', 'confirm')] },
);Why this exists
Angular runs a control's validators inside the control's own constructor, before it has been attached to a parent. A cross-field validator on the CHILD — like match — therefore looks up a sibling that is not reachable yet, returns null, and is never re-run by the act of joining the group. formControlName revalidates when it binds, so a rendered form corrects itself on its first change detection; anything that reads validity before that does not. A route guard or resolver checking form.valid, a form built and submitted from a service, a spec — all see a group that is plainly mismatched and plainly valid. A validator on the GROUP has no such window: Angular attaches the children and aggregates their values before it runs the group\'s own validators, so it is right the moment the group exists.
Pair it with match
<wr-form-field> renders messages for the one control projected into it, so a group-level error is not something it can show. Run both: matchFields on the group makes the form correct from the first frame, and match on the confirm control supplies the per-field sentence once the field is touched. They do not conflict — different error keys, different hosts.
const form = new FormGroup(
{
password: new FormControl(''),
// match — gives the confirm field its own message, once touched
confirm: new FormControl('', [WrValidators.match('password')]),
},
// matchFields — makes the form correct from the first frame
{ validators: [WrValidators.matchFields('password', 'confirm')] },
);Paths and arity
Names are AbstractControl.get() paths, so nested groups and FormArray indexes work with no special handling. Pass more than two names and every one is compared against the first.
// Names are AbstractControl.get() paths.
WrValidators.matchFields('billing.zip', 'shipping.zip');
// N-ary: every name is compared against the first.
WrValidators.matchFields('a', 'b', 'c');API
| Name | Description | Type | Default |
|---|---|---|---|
signature | Factory — call with two or more control names. Attach the result to the GROUP that owns them. | (first: string, second: string, ...rest: readonly string[]) => ValidatorFn | — |
lookup | Names are AbstractControl.get() paths, so billing.zip reaches into a nested group and items.0 into a FormArray. Name a LEAF: a name that resolves to a group or an array is refused too, because comparing two containers by reference can never succeed. Either case turns the whole check off — never a partial comparison — and warns once per validator instance in dev. | — | — |
error key | On mismatch: { matchFields: { fields: [...] } }, on the GROUP. The payload echoes the configured names and deliberately carries no control values. | { matchFields: { fields } } | — |
empty values | null, undefined and "" count as the same empty value. All empty passes; one filled against one empty reports. | — | — |
comparison | Strict ===, so two Date objects for the same instant are NOT equal — compare a derived primitive instead. | — | — |
disabled controls | Skipped, so the rule compares exactly what group.value contains. Fewer than two enabled names short-circuits to valid. | — | — |