Whenever it comes to building forms, I tend to go with reactive forms. In short, while using reactive forms: you create the form model consisting of different form controls directly in the component class. Then you bind the controls to actual native form elements defined in the view.

In this post, I'm going to add a custom asynchronous validator to a FormControl. For demonstration, I'm creating a simple user form. User can enter their name and email. But if the provided email already exists in the server, the user will get an error.

Following is the structure of the user component class,

import { Component, OnInit, AfterViewInit } from "@angular/core";
import {
  FormGroup,
  FormBuilder,
  Validators,
} from "@angular/forms";

import { CustomEmailValidator } from "../shared/custom-email.validator";

@Component({
  selector: "app-user",
  templateUrl: "./user.component.html",
  styleUrls: ["./user.component.scss"]
})
export class UserComponent implements OnInit {
  userForm: FormGroup;

  get name() {
    return this.userForm.get("name");
  }

  get email() {
    return this.userForm.get("email");
  }

  constructor(
    private fb: FormBuilder,
    private emailValidator: CustomEmailValidator
  ) {}

  ngOnInit() {
    this.createForm();
  }

  createForm() {
    this.userForm = this.fb.group({
      name: ["", [Validators.required]],
      email: [
        "",
        [Validators.required, Validators.email],
        [this.emailValidator.existingEmailValidator()]
      ]
    });
  }

  onSubmit() {
    console.log(this.userForm.value);
  }
}
user.component.ts

Notice carefully, we already have synchronous validation constraints on both name and email i.e. the fields can't be null or empty (Validators.required) and additionally, the email should be in a valid format (Validators.email). Another validation constraint on the email is an asynchronous one, that checks for the uniqueness of the provided email on the server. You specified the synchronous validators in an array and pass it as the second parameter to the FormControl. Similarly, you specified your asynchronous validators and pass them as the third parameter to the control.

You don't have to use arrays if you have a single synchronous or asynchronous validator

Angular doesn't fire the asynchronous validators until every synchronous validation is satisfied. Since an asynchronous validation is resourceful (i.e. you would call some kind of server to do the validation), you shouldn't always initiate the validation process on every change made to the form control.

In the case of asynchronous validators, we define a function that returns a function that is of type AsyncValidatorFn. Following is the structure of the AsyncValidatorFn interface,

interface AsyncValidatorFn { 
  (c: AbstractControl): Promise<ValidationErrors|null>|Observable<ValidationErrors|null>
}

The AsyncValidatorFn returns a promise or observable. We process the validation result and decide whether it satisfies a specific validation constraint or not by resolving the promise or the observable object. Following is the validator function that checks the uniqueness of the email available in the control.value:

import { Injectable } from "@angular/core";
import { AbstractControl, AsyncValidatorFn } from "@angular/forms";
import { Observable, of } from "rxjs";
import { map, debounceTime, take, switchMap } from "rxjs/operators";

import { UserService } from "../services/user.service";

function isEmptyInputValue(value: any): boolean {
  // we don't check for string here so it also works with arrays
  return value === null || value.length === 0;
}

@Injectable({
  providedIn: "root"
})
export class CustomEmailValidator {
  constructor(private userService: UserService) {}

  existingEmailValidator(initialEmail: string = ""): AsyncValidatorFn {
    return (
      control: AbstractControl
    ):
      | Promise<{ [key: string]: any } | null>
      | Observable<{ [key: string]: any } | null> => {
      if (isEmptyInputValue(control.value)) {
        return of(null);
      } else if (control.value === initialEmail) {
        return of(null);
      } else {
        return control.valueChanges.pipe(
          debounceTime(500),
          take(1),
          switchMap(_ =>
            this.userService
              .getByEmail(control.value)
              .pipe(
                map(user =>
                  user ? { existingEmail: { value: control.value } } : null
                )
              )
          )
        );
      }
    };
  }
}
custom-email.validator.ts
AbstractControl is the base class of every FormControl.

In the else statement, subscription to the getByEmail(email: string) is fired when the control is not in a debounced state (For drastically minimizing the http calls). Observable that is returned from the getByEmail(email: string) is mapped and here we check whether we have a unique email or not. If yes, we return a validation key i.e. existingEmail with the value set to the form control's value { value: control.value }. And if not, we return a null object.

The getByEmail(email: string) function is available in the user.service.ts file. We are mimicking a server call with a delay here.

import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { delay } from "rxjs/operators";

import { User, users } from "../models/user";

@Injectable({
  providedIn: "root"
})
export class UserService {
  constructor() {}

  getByEmail(email: string): Observable<User | undefined> {
    const user = users.find(user => user.email === email);
    return of(user).pipe(delay(500));
  }
}
user.service.ts

HTML markup of the component is as following

<div class="columns">
  <div class="column is-one-third">
    <form [formGroup]="userForm" (ngSubmit)="onSubmit()" novalidate>
      <div class="field">
        <label class="label" for="name">Name</label>
        <div class="control">
          <input
            class="input is-primary"
            type="text"
            id="name"
            formControlName="name"
          />
          <div *ngIf="name.invalid && (name.dirty || name.touched)">
            <p class="help is-danger" *ngIf="name.errors.required">
              Name is required.
            </p>
          </div>
        </div>
      </div>

      <div class="field">
        <label class="label" for="email">Email</label>
        <div class="control">
          <input
            class="input is-primary"
            type="email"
            id="email"
            formControlName="email"
          />
          <div *ngIf="email.invalid && (email.dirty || email.touched)">
            <p class="help is-danger" *ngIf="email.errors.required">
              Email is required.
            </p>
            <p class="help is-danger" *ngIf="email.errors.email">
              Email is not valid.
            </p>
            <p class="help is-danger" *ngIf="email.errors.existingEmail">
              Email already exists.
            </p>
          </div>
        </div>
      </div>

      <div class="field">
        <div class="control">
          <button
            class="button is-primary"
            type="submit"
            [disabled]="userForm.pristine || userForm.invalid"
          >
            Save
          </button>
        </div>
      </div>
    </form>
  </div>
  <div class="column">
    <pre class="user-pre">{{ userForm.value | json }}</pre>
  </div>
</div>
user.component.html

Remember that we have the validation result returned with a key named existingEmail. So, we can check whether the value of the key is null or not in a conditional *ngIf directive and toggle a relevant message.

That is how easy to make custom asynchronous validators. I hope you like the post. Share it and have a nice day ☺.

Repository

https://github.com/fiyazbinhasan/ng-playground/tree/ng-playground-async-validation

fiyazbinhasan/ng-playground
Contribute to fiyazbinhasan/ng-playground development by creating an account on GitHub.

Links:

https://angular.io/guide/reactive-forms

https://angular.io/guide/form-validation#custom-validators

https://www.learnrxjs.io/operators/transformation/switchmap.html