In Angular reactive forms, how do you implement a validator that checks the password and confirm-password fields match, and where does its error appear?
answer
- one field cannot see the other
- attach it to the parent
- the validators option on FormGroup
- form.hasError('passwordMismatch')
basics
~20 sWrite a ValidatorFn that reads both child values and returns an error such as {passwordMismatch: true}, and attach it to the FormGroup through the validators option. The error lands on the group's errors, not on either control.
solid answer
~40 sA validator on a single `FormControl` only sees that control's value, so a match check belongs on the **parent**. Write a `ValidatorFn` that takes the group, reads `password` and `confirmPassword` with `group.get(...)`, and returns `{passwordMismatch: true}` or `null`. Attach it with `new FormGroup({...}, {validators: passwordsMatch})` or the same options object on `fb.group`. Because a child's value change propagates up and re-runs the parent's validators, the check runs whenever either field changes. The error sits in `form.errors`, making the group `INVALID` while both controls stay individually valid, so the template reads `form.hasError('passwordMismatch')`, usually gated on the confirm field being touched. Writing the error onto the child with `setErrors()` is fragile: the child's next own validation run replaces it.
code
ts · 24 linesimport {Component, inject} from '@angular/core';
import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {passwordsMatch} from './passwords-match';
@Component({
selector: 'app-sign-up',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input type="password" formControlName="password" />
<input type="password" formControlName="confirmPassword" />
@if (form.hasError('passwordMismatch') && form.controls.confirmPassword.touched) {
<p>Passwords do not match.</p>
}
</form>
`,
})
export class SignUp {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group(
{password: ['', [Validators.required, Validators.minLength(8)]], confirmPassword: ['', Validators.required]},
{validators: passwordsMatch},
);
}go deeper
Recall that a password-match rule goes on the FormGroup through its validators option and that the error is read with form.hasError().
Explain how child changes propagate to the parent and re-run group validators, and why the error sits on the group while children stay valid.
Keep the validator pure and parameterised, avoid setErrors side effects on children, and design the message so required and mismatch never fire for the same problem.
Decide where relational rules live across the app, as shared validator factories with a naming convention for error keys that UI copy can rely on.
## Why a control-level validator cannot do it In Angular's reactive forms, a `ValidatorFn` receives exactly one `AbstractControl`. A validator attached to `confirmPassword` sees only its own value. It could reach sideways through `control.parent`, but then it re-runs only when **confirm** changes: edit the password afterwards and the stale verdict stays. The rule depends on two values, so it belongs to the node that owns both of them, the `FormGroup`. ## Writing the group validator A group validator is an ordinary `ValidatorFn`; the control it receives happens to be the group: ```ts import {AbstractControl, ValidationErrors, ValidatorFn} from '@angular/forms'; export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => { const password = group.get('password')?.value; const confirm = group.get('confirmPassword')?.value; if (!password || !confirm) return null; // let required report empty fields return password === confirm ? null : {passwordMismatch: true}; }; ``` Attach it through the options object, which is `AbstractControlOptions` with `validators`, `asyncValidators` and `updateOn`: ```ts import {FormControl, FormGroup, Validators} from '@angular/forms'; const signUp = new FormGroup( { password: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(8)]}), confirmPassword: new FormControl('', {nonNullable: true, validators: [Validators.required]}), }, {validators: passwordsMatch}, ); ``` With a builder it is `fb.group({...}, {validators: passwordsMatch})`. ## When it runs Angular's validation order on a value change is: 1. The changed control runs its own validators and updates its `errors` and `status`. 2. The change propagates up: the parent group recomputes its value and runs **its** validators. 3. The group's status is derived from its own `errors` first, then from its children. So editing either field re-runs `passwordsMatch`. You do not need a `valueChanges` subscription to re-trigger it. ## Where the error lives | Place | After a mismatch | |---|---| | `signUp.errors` | `{passwordMismatch: true}` | | `signUp.status` | `INVALID` | | `signUp.controls.confirmPassword.errors` | `null` (still valid on its own) | | CSS on the confirm `<input>` | still `ng-valid` | That last row matters for UI. The template must ask the **group**: ```html @if (signUp.hasError('passwordMismatch') && signUp.controls.confirmPassword.touched) { <p>Passwords do not match.</p> } ``` If you want the red border on the confirm field too, style it from the same condition rather than relying on `ng-invalid`. ## Why not re-validate the other control by hand? An alternative some codebases use is a control-level validator on `confirmPassword` plus a subscription: whenever `password.valueChanges` emits, call `confirmPassword.updateValueAndValidity()`. It works, and it puts the error on the confirm field, but it costs more than it gives: - a subscription that has to be cleaned up with the component; - a validator that reaches into `control.parent`, which is `null` until the control is attached; - an ordering dependency that breaks silently if someone renames a field. The group validator needs none of that, because propagation to the parent already happens on every change. Prefer it, and style the confirm input from the group's error. ## Design choices a reviewer looks for - **Return `null` while a field is empty**, so `required` owns the empty case and the user does not see two messages for one problem. - **Parameterise for reuse**: a factory such as `matchFields('password', 'confirmPassword')` returns the `ValidatorFn`, so a date-range or email-confirm check uses the same code. - **Keep it pure**: the function should only read values and return a result. Side effects such as calling `setErrors()` on a child are overwritten the next time that child validates, and they emit extra status events. - **Typed forms still work**: the parameter is `AbstractControl`, so use `group.get('password')` or narrow the type if you want the typed `controls`. ## The same idea in template-driven forms Template-driven forms have no group object you construct yourself, so the equivalent is a directive on the `<form>` or on an `ngModelGroup` that provides the function through `NG_VALIDATORS`. The function body is identical; only the wiring changes. ## Common mistakes 1. Putting the check on the confirm control and missing re-validation when the password changes. 2. Displaying the message from `confirmPassword.errors`, which never contains the mismatch key. 3. Using the legacy builder shape `fb.group(controls, {validator: fn})` with the singular key. That format is deprecated; pass `{validators: fn}`, the `AbstractControlOptions` shape. 4. Forgetting that a disabled child is excluded from the group's `value` but still reachable with `group.get()`, so decide deliberately what a disabled field means for the rule.
- Why not call confirmPassword.setErrors({passwordMismatch: true}) from the group validator so the input turns red?Because `setErrors()` is overwritten the next time that control validates itself: `updateValueAndValidity()` replaces `errors` with its own validators' result. The error then flickers or disappears, and the validator has hidden side effects. Keep the error on the group and style the input from `form.hasError('passwordMismatch')`.
- How would you make the match validator reusable for an email-confirmation pair?Turn it into a factory: `matchFields(a: string, b: string): ValidatorFn` returns a function that reads both paths with `group.get()` and returns a named error such as `{fieldsMismatch: {a, b}}`. Each form attaches its own instance through the group's `validators` option.
- Does the group validator run if password is still failing its own minLength validator?Yes. Sync validators on the group run on every propagated change regardless of the children's status. Only async validators are skipped when sync validation fails. That is why the match function should return `null` for empty values and let the child validators report their own problems.
It is like checking that two signatures on a contract match: neither signature line can do it alone, so the check is done by the clerk holding the whole contract, and the note goes on the contract, not on either line.
saying these in an interview costs you the question
- Put the match check on the confirm control; it re-runs when the password changes.
- The mismatch error appears in confirmPassword.errors.
- You need a valueChanges subscription to re-run a group validator.
- Group validators only run after every child is valid.
- Calling setErrors on the child from the group validator is the robust approach.