menu

Working with async validators in Angular

There are numerous occasions on which you'll found yourself needing to retrieve data from an external source, in order to validate user input. For example:

The great thing is that this functionality is built right into Reactive Forms: asynchronous validators.

About asynchronous validators

An asynchronous validator is just a function which returns another function. Simple as that.

The function it returns takes a form control as an argument and returns a promise or observable of type ValidationErrors. If there are no errors, the function returns a promise or observable of type null.


interface AsyncValidatorFn {
  (control: AbstractControl<any, any>): Promise<ValidationErrors | null> | Observable<ValidationErrors | null>
}

Let's try to solve the first use case above. Assume we have a website which contains recipes, and users can enter a recipe themselves using a form. Every recipe has a title and a description.

However, we don't want to allow users to add a recipe with a title of an already existing recipe. Why have the same recipe displayed twice, right?

This means we need to:

Create the validator function

Our validator function could look like this:


function recipeTitleAsyncValidator(recipeService: RECIPES): AsyncValidatorFn {
  return (control: AbstractControl) => Observable<ValidationErrors | null> {
    this.recipeService.isRecipeTitleInUse(control.value).pipe(
      map((result: {recipeTitleInUse: boolean}) => {
        return result.recipeTitleInUse ? ({recipeTitleInUse: true}) : null;
      })
    );
  }
}

Notice how we're passing in the service which handles the API call as an argument to the function.

Moreover, you can see that we have also mapped the API response to an observable of value {recipeTitleInUse: true} (when the recipe name is already taken) or null (when it is not). This complies with the ValidationErrors type.


  type ValidationErrors = {
    [key: string]: any;
  };

The possible values for this object are not limited to boolean values. You could pass any other value and utilize it inside the component or template (e.g. showing in the error message how many items with a similar title already exist).

Register our validator with the form control

There are 3 main methods to register an asynchronous validator. You can register it directly in the constructor of a FormControl:


const recipeForm = new FormGroup({
  title: new FormControl(null, Validators.required, recipeTitleAsyncValidator)
});

Our asynchronous validator is the third argument in the constructor. The second argument is reserved for synchronous validators.

A second option is to use FormBuilder.


const recipeForm = this.formBuilder.group({
  title: [null, Validators.required, recipeTitleAsyncValidator(this.recipeService)]
});

I always prefer FormBuilder for large forms because the syntax is more or less the same, but it is a little less verbose.

The third and last option is the most verbose. Instead of setting the validators upon initialization of the form control, we add the validators afterwards.

This can be useful in cases where validation depends on one or multiple conditions. For example, if we only needed to validate the recipe title if the recipe is not added by an administrator.


const recipeForm = this.formBuilder.group({
  title: [null, Validators.required]
});

if (!isAdmin) {
  recipeForm.get('title').addAsyncValidators(recipeTitleAsyncValidator(this.recipeService));
  recipeForm.get('title').updateValueAndValidity();
}

Easy to forget but we have to call updateValueAndValidity for the new validator(s) to take effect.

Note that we could refactor the example above using a ternary operator during the initialisation phase.


const recipeForm = this.formBuilder.group({
  title: [null, Validators.required, isAdmin ? null : recipeTitleAsyncValidator(this.recipeService)]
});

But in cases where the condition isAdmin would be fetched asynchronously, this won't work. If isAdmin changes, the validator will not suddenly be removed. That means there is definitely a use case for adding the validators after initialisation.

If validators can be added manually, can we remove them manually? Duh!


const recipeForm = this.formBuilder.group({
  title: [null, Validators.required]
});

if (isAdmin) {
  recipeForm.get('title').removeAsyncValidators(recipeTitleAsyncValidator(this.recipeService));
  recipeForm.get('title').updateValueAndValidity();
}

Take a good look at the Angular documentation for AbstractControl (the parent type of FormGroup, FormControl and FormArray) and you will find two other methods related to asynchronous validators:

Display the validation errors

To display the errors, we need to verify if the errors object of our form control contains a key recipeTitleInUse. A simple ngIf statement does the trick well.

To simplify the example, we use the form field and error components from Material UI.


<mat-form-field>
  <mat-label>Title</mat-label>
  <input placeholder="Recipe title" type="text" [formControl]="title">
</mat-form-field>
<mat-error *ngIf="recipeForm.get('title').hasError('recipeTitleInUse')">
  This recipe name is already in use.
</mat-error>

This approach is exactly the same for synchronous validators.

Improving the validator

While testing the example above, you'll notice there is room for improvement. In certain cases, a validation call is executed but obsolete:

Let's see how we would tackle these issues:


function recipeTitleAsyncValidator(recipeService: RECIPES): AsyncValidatorFn {
  return (control: AbstractControl) => Observable<ValidationErrors | null> {
    if (control.value === '' || control.value === null || control.value === undefined) {
      return null;
    } else {
      of(control.value).pipe(
        delay(1000),
        distinctUntilChanged(),
        switchMap((value: string) => this.recipeService.isRecipeTitleInUse(value)),
        map((result: {recipeTitleInUse: boolean}) => {
          return result.recipeTitleInUse ? ({recipeTitleInUse: true}) : null;
        })
      );
    }
  }
}

First of all, we're returning null when the input value is empty. Why? Because the Validators.required validator takes care of invalidating the input when it is empty.

Second, we're adding a delay of 1 second before executing the HTTP call. If a new value comes in during this delay, the validation will be cancelled and a new validation will start with the updated values.

You could argue that debounceTime should be the RxJS operator of choice here. But the thing is, the asynchronous validator sort of debounces this for us. Behind the scenes, it performs a new validation every time the input changes, and at the same time cancels the previous subscription (or rejects the previous promise).

Finally, we added the distinctUntilChanged operator, in order to prevent executing an HTTP call if the previous value is equal to the current value. This can for instance occur when the user pastes the same value into the input field, or when the user types a new value but, within the delay of 1000 ms, changes it back to the old value.

Testing our asynchronous validator

We've verified that our asynchronous validator works and have improved it. As a final step, how can we write a unit test for this?

Basically, we create a new FormControl, patch the value and check if the errors object contains the expected values.


import ...

describe('recipeTitleAsyncValidator', () => {
  let recipeService: RECIPES;
  let validator: AsyncValidatorFn;
  let recipeTitleFormControl: FormControl;

  beforeEach(
    waitForAsync(() => {
      TestBed.configureTestingModule({
        providers: [{provide: RECIPES, useClass: RecipeService}],
      });
    })
  );

  beforeEach(() => {
    recipeService = TestBed.inject(RECIPES);
    validator = recipeTitleAsyncValidator(recipeService);
    recipeTitleFormControl = new FormBuilder().control('', [], validator);
  });

  describe('if recipe title is in use', () => {
    it('should return a recipeTitleInUse error', fakeAsync(() => {
      spyOn(recipeService, 'isRecipeTitleInUse').and.returnValue(of({recipeTitleInUse: true}));
      recipeTitleFormControl.patchValue('Lasagna Bolognese');

      tick(500);

      expect(recipeTitleFormControl.hasError('recipeTitleInUse')).toBeTrue();
    }));
  });
});

In short:

And with that, we can also wrap up this post! I hope you enjoyed it and let me share some of the sources if you're hoping to learn even more:

⇤ Return to blog overview