Angular Cross-Field Password-Match Validator

This recipe builds a group-level Angular validator that compares a password and its confirmation field, sets the error on the right node, and renders it accessibly — the framework-native expression of the manual matching logic in Cross-Field Password Confirmation Logic.

When to Use This Recipe

A password and its confirmation are not independent — neither field is “invalid” on its own; they are invalid only in relation to each other. That makes a single-control ValidatorFn the wrong tool. Use a group-level validator when:

  • Two or more sibling controls must agree (password/confirm, start/end dates, min/max bounds).
  • The error belongs to the relationship, not to one field’s intrinsic format.
  • You want the comparison to re-run whenever either field changes, which a group validator does automatically.

If the rule only inspects one field’s value in isolation, a plain control-level validator is simpler. Reach for the group when the rule reads two or more controls. For the framework-agnostic theory, see Cross-Field Validation Strategies.

Group-level password match validation The group validator reads the password and confirm controls, compares the values, and either clears the error or sets passwordMismatch on the group, which the accessible template renders under the confirm field. password control confirm control group validator a === b ? null → group valid { passwordMismatch } on the group
The group validator compares both controls and either clears the error or sets passwordMismatch on the FormGroup itself.

Minimal Working Implementation

Write the validator as a standalone ValidatorFn that receives the group, then attach it in the group’s options.

// validators/passwords-match.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

/**
 * Group-level validator. Returns null when the fields agree (or confirm is
 * empty), and { passwordMismatch: true } on the GROUP when they differ.
 */
export function passwordsMatch(
  passwordKey = 'password',
  confirmKey = 'confirmPassword',
): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get(passwordKey)?.value ?? '';
    const confirm = group.get(confirmKey)?.value ?? '';

    // Do not flag a mismatch until the user has typed in the confirm field —
    // the same suppression rule used in the framework-agnostic version.
    if (!confirm) return null;

    return password === confirm ? null : { passwordMismatch: true };
  };
}
// signup-form.component.ts
import { Component, inject } from '@angular/core';
import {
  NonNullableFormBuilder,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';
import { passwordsMatch } from './validators/passwords-match.validator';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  private fb = inject(NonNullableFormBuilder);

  form = this.fb.group(
    {
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', [Validators.required]],
    },
    // The cross-field validator lives in the group's options, not on a control.
    { validators: passwordsMatch('password', 'confirmPassword') },
  );

  onSubmit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    this.register(this.form.getRawValue());
  }
}

Because the error is set on the group, the natural place to render it is under the confirm field while reading from form.errors. The template also guards on touched so the message does not appear before interaction — the same premature-feedback guard the manual implementation uses.

<!-- signup-form.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div class="form-group">
    <label for="password">Password</label>
    <input id="password" type="password" formControlName="password" autocomplete="new-password" />
    @if (form.controls.password.touched && form.controls.password.errors?.['minlength']) {
      <p class="error" role="alert">At least 8 characters.</p>
    }
  </div>

  <div class="form-group">
    <label for="confirmPassword">Confirm Password</label>
    <input
      id="confirmPassword"
      type="password"
      formControlName="confirmPassword"
      autocomplete="new-password"
      [attr.aria-invalid]="form.errors?.['passwordMismatch'] && form.controls.confirmPassword.touched"
      [attr.aria-describedby]="form.errors?.['passwordMismatch'] ? 'confirm-error' : null"
    />
    <!-- Read the error from the GROUP, not the control. -->
    @if (form.errors?.['passwordMismatch'] && form.controls.confirmPassword.touched) {
      <p id="confirm-error" class="error" role="alert">Passwords do not match.</p>
    }
  </div>

  <button type="submit">Create Account</button>
</form>

The role="alert" node and the conditional aria-describedby satisfy WCAG SC 3.3.1 (Error Identification), associating the relationship error with the field the user can act on — the same accessible-error contract native validation establishes through validationMessage, and the binding that aria-invalid provides on a native control.

Option Reference

Symbol / option Type Purpose
passwordsMatch(pwKey, confirmKey) ValidatorFn factory Configurable group validator
group.get(key)?.value any Reads a sibling control’s value
{ passwordMismatch: true } ValidationErrors Error set on the group, read via form.errors
{ validators: passwordsMatch(...) } AbstractControlOptions Attaches the validator at group construction
form.errors?.['passwordMismatch'] unknown Selects the error in the template
control.touched boolean Gates premature error display
markAllAsTouched() method Reveals errors on submit

Styling the Mismatch State

Because the error lives on the group rather than the control, Angular’s automatic .ng-invalid class is not applied to the confirm input for a cross-field failure — that class only reflects a control’s own validators. To style the confirm field on mismatch, drive the styling off aria-invalid, which you already bind from form.errors. This keeps the visual state and the accessible state in lockstep and works regardless of where the error is attached.

/* Style from the accessibility attribute, not the framework class, so the
   group-level error is reflected on the confirm field. */
input[aria-invalid='true'] {
  border-color: #ef4444;
}

/* Reserve space for the message to avoid layout shift when it appears. */
.form-group .error {
  min-height: 1.25rem;
  color: #b91c1c;
}

Maintain a 4.5:1 contrast ratio for the error text against its background to satisfy WCAG SC 1.4.3, the same threshold the UX Patterns & Error State Design guidance applies to every error state.

Verification Steps

Confirm the error attaches to the group and renders accessibly with a Playwright check.

import { test, expect } from '@playwright/test';

test('password mismatch is flagged on the confirm field', async ({ page }) => {
  await page.goto('/signup');
  await page.getByLabel('Password').fill('correct-horse');
  await page.getByLabel('Confirm Password').fill('wrong-horse');
  await page.getByLabel('Confirm Password').blur();

  const error = page.locator('#confirm-error');
  await expect(error).toHaveText('Passwords do not match.');
  await expect(error).toHaveAttribute('role', 'alert');
  await expect(page.getByLabel('Confirm Password')).toHaveAttribute('aria-invalid', 'true');

  // Fixing the confirm field clears the group error
  await page.getByLabel('Confirm Password').fill('correct-horse');
  await expect(error).toHaveCount(0);
});

In your component spec, assert directly against the group: after setting mismatched values, expect component.form.errors?.['passwordMismatch'] to be truthy and component.form.controls.confirmPassword.errors to be null.

Edge Cases and Failure Modes

1. Reading the error from the wrong node. The error lives on the group. form.controls.confirmPassword.errors returns null for a mismatch — read form.errors instead.

// Before — always null, message never shows
form.controls.confirmPassword.errors?.['passwordMismatch']
// After — the group holds the cross-field error
form.errors?.['passwordMismatch']

If you prefer the error attached to the control for styling reasons, copy it in the validator with group.get(confirmKey)?.setErrors({ passwordMismatch: true }) — but then you must also clear it when they match, or it sticks.

2. Mismatch flagged before the user types confirm. Without the if (!confirm) return null guard, the form is invalid from first paint. Keep the guard so the relationship error only appears once both fields have content.

3. Stale error after fixing the password field. Because the validator runs whenever either control changes, correcting the password re-runs the comparison and clears the error automatically — provided you read live from form.errors rather than caching the result in a component property.

Frequently Asked Questions

Why set the error on the group instead of the confirm control?

A mismatch is a property of the relationship between two controls, not of either control alone. Attaching it to the group means the validator re-runs whenever either field changes, and neither field is wrongly marked intrinsically invalid. You then render the message under the confirm field by reading form.errors.

How does this map to native form validation?

The native Constraint Validation API has no built-in cross-field rule, so the equivalent is calling setCustomValidity() on the confirm input after a manual comparison. The Angular group validator is the reactive, testable version of that pattern, with form.errors standing in for the custom validity flag and aria-describedby providing the same accessible association.

Can I reuse this validator for date ranges or min/max bounds?

Yes — the structure is identical. Change the keys and the comparison: read two controls, return null when they satisfy the relationship, and a keyed error object when they do not. The same group-level placement and accessible rendering apply.

← Back to Angular Reactive Forms Validation