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.
The post Creating a Multi-Control Custom Validator in Angular appeared first on Hapax Legomenon.
Top comments (0)