Creating a Multi-Control Custom Validator in Angular

Sunset
Sunset

Custom validators in Angular’s reactive form library are one of the most powerful (and in my opinion overlooked) tools a developer has to create better form UI/UX. Custom validators aren’t just limited to a single control. It is easy to evaluate an entire group. This is great for comparing multiple controls. In this article I create a multi-control custom validator that validates two fields if their values match to show an example of what is possible.

As I mentioned in my previous article about custom validators, I like using them to both handle custom logic that the built-in validators don’t, and to be able to create the validation error messages in one spot. This makes custom validators powerful and very reusable.

Creating a Multi-Control Custom Validator

Creating a multi-control custom validator is very similar to creating a single-control one. The validator needs a passed in AbstractControl parameter. In single-control validators, the control is normally a FormControl. However, for multi-control validators, I need to pass in the parent FormGroup as the control. Doing this gives me the scope of all of the children controls inside of the FormGroup. To make this validator more reusable, I also pass in the names of the controls I want to compare. I also can pass in the name of the kind of values I am comparing to make the error messages more dynamic.

I then create variables for the values from the form controls. Once I have those, I set up some simple conditionals. Since I passed in the FormGroup as the AbstractControl instead of a specific FormControl, if I want to set errors on the FormControls, I need to call setErrors() on the specific control. Otherwise, if I just return the ValidationErrors, they will apply to the FormGroup, which isn’t what I want here.

export class MatchFieldValidator {
  static validFieldMatch(
    controlName: string,
    confirmControlName: string,
    fieldName: string = 'Password',
  ): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
      const controlValue: unknown | null = control.get(controlName)?.value;
      const confirmControlValue: unknown | null = control.get(
        confirmControlName,
      )?.value;

      if (!confirmControlValue) {
        control.get(confirmControlName)?.setErrors({
          confirmFieldRequired: `Confirm ${fieldName} is required.`,
        });
      }

      if (controlValue !== confirmControlValue) {
        control
          .get(confirmControlName)
          ?.setErrors({ fieldsMismatched: `${fieldName} fields do not match.` });
      }

      if (controlValue && controlValue === confirmControlValue) {
        control.get(confirmControlName)?.setErrors(null);
      }

      return null;
    };
  }
}

Now that I have a working validator, I need to wire it up to the component. Since I want to be to interact with multiple FormControls, I need to attach the validator to the parent FormGroup. The FormBuilder takes an options argument after the control config where I can pass in validators. I add the match field validator, along with the names of the controls I want to compare, and what kind of field I’m comparing. I’ve simplified the below code to just focus on what is relevant:

private createForm(): FormGroup {
  const form = this.fb.group({
    password: [
      '',
      Validators.compose([PasswordValidator.validPassword(true)]),
    ],
    confirmPassword: [''],
  },
  {
    validators: Validators.compose([
      MatchFieldValidator.validFieldMatch('password', 'confirmPassword', 'Password'),
    ]),
  });

  return form;
}

As I now having working validation, I can bind the errors to the template. I am still using the loop through the errors object via the KeyValuePipe for simplicity.

<div class="field-group">
  <mat-form-field>
    <input
      name="password"
      id="password"
      type="password"
      matInput
      placeholder="Password"
      formControlName="password"
    />
    <mat-error *ngIf="form.get('password')?.errors">
      <ng-container *ngFor="let error of form.get('password')?.errors | keyvalue">
        <div *ngIf="error.key !== 'required'">{{ error.value }}</div>
      </ng-container>
    </mat-error>
  </mat-form-field>
  <mat-form-field>
    <input
      name="confirmPassword"
      id="confirmPassword"
      type="password"
      matInput
      placeholder="Confirm Password"
      formControlName="confirmPassword"
      required
    />
    <mat-error *ngIf="form.get('confirmPassword')?.errors">
      <ng-container *ngFor="let error of form.get('confirmPassword')?.errors | keyvalue">
        <div *ngIf="error.key !== 'required'">{{ error.value }}</div>
      </ng-container>
    </mat-error>
  </mat-form-field>
</div>

Testing the Validator

Like other custom validators, it is easy to test multi-control custom validators. Writing unit tests for this validator helped me find and handle an edge case that I wasn’t handling initially also. Here are some of the example tests:


  describe('validFieldMatch() default field name', () => {
    const matchFieldValidator = MatchFieldValidator.validFieldMatch(
      'controlName',
      'confirmControlName',
    );
    const form = new FormGroup({
      controlName: new FormControl(''),
      confirmControlName: new FormControl(''),
    });
    const controlName = form.get('controlName');
    const confirmControlName = form.get('confirmControlName');

    it(`should set control error as { confirmFieldRequired: 'Confirm Password is required.' } when value is an empty string`, () => {
      controlName?.setValue('');
      confirmControlName?.setValue('');
      matchFieldValidator(form);
      const expectedValue = {
        confirmFieldRequired: 'Confirm Password is required.',
      };
      expect(confirmControlName?.errors).toEqual(expectedValue);
    });

    it(`should set control error as { fieldsMismatched: 'Password fields do not match.' } when values do not match`, () => {
      controlName?.setValue('password123!');
      confirmControlName?.setValue('password123');
      matchFieldValidator(form);
      const expectedValue = {
        fieldsMismatched: 'Password fields do not match.',
      };
      expect(confirmControlName?.errors).toEqual(expectedValue);
    });

    it(`should set control error as null when values match`, () => {
      controlName?.setValue('password123!');
      confirmControlName?.setValue('password123!');
      matchFieldValidator(form);
      expect(controlName?.errors).toEqual(null);
      expect(confirmControlName?.errors).toEqual(null);
    });

    it(`should set control error as null when control matches confirm after not matching`, () => {
      controlName?.setValue('password123!');
      confirmControlName?.setValue('password123!');
      matchFieldValidator(form);
      controlName?.setValue('password123');
      matchFieldValidator(form);
      controlName?.setValue('password123!');
      matchFieldValidator(form);
      expect(controlName?.errors).toEqual(null);
      expect(confirmControlName?.errors).toEqual(null);
    });

    it(`should set control error as null when confirm matches control after not matching`, () => {
      controlName?.setValue('password123!');
      confirmControlName?.setValue('password123!');
      matchFieldValidator(form);
      controlName?.setValue('password123');
      matchFieldValidator(form);
      confirmControlName?.setValue('password123');
      matchFieldValidator(form);
      expect(controlName?.errors).toEqual(null);
      expect(confirmControlName?.errors).toEqual(null);
    });
  });

Custom validators are easy to make and very powerful. Since they can be made at any level of a reactive form, it is possible to make multi-control custom validators like this one that can interact with multiple controls. This helps developers craft highly reactive UI/UX for users.

Resources

The repository includes unit tests for the validator to help dial in the desired behavior. Here is the repository on GitHub, and here is a working demo of the code on StackBlitz. All of my posts on Angular are tagged and collected here.

Leave a Reply

Your email address will not be published. Required fields are marked *