Angular forms - Multiple cross-field validation

Creating a custom Angular validator is required if there is no built-in Angular validator to match a particular use-case in an application.

The following custom validator is used to compare and validate the value of an input field with the value of its relative confirm input field. In this article the values of email and confirm email, as well as password and confirm password fields are compared and validated.

See my demo application of both reactive and template-driven forms, with unit and e2e tests at GitHub.

Defining the form model in the reactive form's component class

The form model is registered in the component class using the FormBuilder service. Each of the email and password controls is combined with its relative confirm control in a nested form group, this is to enable the comparison and validation of the two values:

export class ReactiveFormComponent implements OnInit {
  reactiveForm = this.formBuilder.group({
    emailGroup: this.formBuilder.group({
      name: [''],
      email: [''],
      confirmEmail: ['']
    }),
    passwordGroup: this.formBuilder.group({
      password: [''],
      confirmPassword: ['']
    })
  });
}

Setting up the reactive form's template

The reactiveForm property in the component class is associated with the template, using the formGroup binding on the form element, the form groups and controls are synced to the fieldset and input elements, using the formGroupName and formControlName directives:

<form [formGroup]="reactiveForm" class="form">
  <fieldset formGroupName="emailGroup">
    <label for="email">Email:</label>
    <input id="email" type="text" formControlName="email" />
    <label for="confirmEmail">Confirm email:</label>
    <input id="confirmEmail" type="text" formControlName="confirmEmail" />
  </fieldset>
  <fieldset formGroupName="passwordGroup">
    <label for="password">Password:</label>
    <input id="password" type="password" formControlName="password" />
    <label for="confirmPassword">Confirm password:</label>
    <input id="confirmPassword" type="password" formControlName="confirmPassword" />
  </fieldset>
</form>

Defining the cross-field validator

The compareInputValidator factory function takes an array of the names of the two form controls to be compared, it returns a validator function, which compares the two control values, it returns null if the two are equal, otherwise it returns the compareValueError object:

export function compareInputValidator(crossFields: Array<string>): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if ((control.get(crossFields[0])?.value && control.get(crossFields[1]))?.value &&
      control.get(crossFields[0])?.value === control.get(crossFields[1])?.value) {
      return null;
    }
    return { compareValueError: true };
  };
}

Adding the cross-field validator to the reactive form's component class

The compareInputValidator directive is imported into the component class and passed to the validators option of each of the email and password groups. An array of the names of the two controls to be validated is passed as an argument to the validator function:

import { compareInputValidator } from '../compare-input-validator.directive';
      
emailGroup: this.formBuilder.group(
  {
    email: ["", [Validators.required, Validators.email]],
    confirmEmail: ["", [Validators.required]]
  },
  { validators: [compareInputValidator(['email', 'confirmEmail'])] }
),
passwordGroup: this.formBuilder.group(
  {
    password: [this.user.password, [Validators.required]],
    confirmPassword: [this.user.confirmPassword, [Validators.required]]
  },
  { validators: [compareInputValidator(['password', 'confirmPassword'])] }
)

Providing feedback to the user with validation error messages

Descriptive error messages are rendered in the template if the compareValueError object is returned from the validator function:

<fieldset formGroupName="emailGroup">
  <input id="confirmEmail" type="text" formControlName="confirmEmail" required>
  @if (emailGroup?.hasError('compareValueError')) {
    <div class="alert alert-error">
      <p data-cy="emailsNoMatch">Email entries do not match.</p>
    </div>
  }
</fieldset>
<fieldset formGroupName="passwordGroup">
  <input id="confirmPassword" formControlName="confirmPassword" required>
  @if (passwordGroup?.hasError('compareValueError')) {
    <div class="alert alert-error">
      <p data-cy="passwordsNoMatch">Password entries do not match</p>
    </div>
  }
</fieldset>