Official Documentation View Source on Github

Highlights

  1. The validator is debounced to avoid doing too many HTTP requests.
  2. Validator directives do not have an easy way to implement debounce. If you implement AsyncValidator, the validate() method is called on each keystroke, and doesn't allow you to debounce.
  3. The validator is built inside a normal directive, with the main logic on the constructor.
  4. When the validation starts call markAsPending(). When the validation ends call setErrors().
  5. Pass the current errors when setting any new error, to not override what other validators might have done.
  6. Set the custom validators by using directives on the fields.
  7. Access the validation errors through ngModel.
  8. Access the state of the validation through ngModel.pending

// app/async-validation/user.ts

export interface User {
  id: number;
  username: string;
  password: string;
}
// app/async-validation/unique-username-validator.directive.ts

import { Directive, OnDestroy } from '@angular/core';
import { NgModel } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, filter, switchMap, tap } from 'rxjs/operators';
import { UserService } from './user.service';

// For more context see: https://mobiarch.wordpress.com/2017/12/26/angular-async-validator/
@Directive({
  selector: '[appUniqueUsername]',
})
export class UniqueUsernameValidatorDirective implements OnDestroy {
  private readonly subscription?: Subscription;

  constructor(userService: UserService, { control }: NgModel) {
    this.subscription = control.valueChanges
      .pipe(
        // If you filter out empty strings, you cannot use distinctUntilChanged() later
        filter((username) => !!username),
        tap(() => control.markAsPending()),
        debounceTime(500),
        switchMap((username) => userService.isUsernameAvailable(username))
      )
      .subscribe((available) => {
        if (!control.value || available) {
          // This is needed to report the validation is not pending anymore
          return control.setErrors(control.errors);
        }
        control.setErrors({
          ...control.errors,
          uniqueUsername: { value: control.value },
        });
      });
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}
<!-- app/async-validation/sign-up-form-template/sign-up-form-template.component.html -->

<h1>Sign Up Form (template-based)</h1>

<mat-card>
  <form (ngSubmit)="submit()" #signUpForm="ngForm">
    <mat-form-field>
      <mat-label>Username</mat-label>
      <input
        matInput
        required
        appUniqueUsername
        name="username"
        [(ngModel)]="user.username"
        #username="ngModel"
      />
      <mat-spinner
        matSuffix
        [diameter]="18"
        *ngIf="username.pending"
      ></mat-spinner>
      <mat-error *ngIf="username.errors?.required"
        >You must enter a value</mat-error
      >
      <mat-error *ngIf="username.errors?.uniqueUsername"
        >The username has already been taken</mat-error
      >
    </mat-form-field>
    <mat-form-field>
      <mat-label>Password</mat-label>
      <input
        matInput
        required
        type="password"
        name="password"
        [(ngModel)]="user.password"
        #password="ngModel"
      />
      <mat-error *ngIf="password.errors?.required"
        >You must enter a value</mat-error
      >
    </mat-form-field>
    <div class="form-buttons">
      <button
        mat-raised-button
        type="submit"
        color="primary"
        [disabled]="loading || !signUpForm.form.valid"
      >
        Submit
      </button>
    </div>
  </form>
</mat-card>

<h2>User (JSON)</h2>

<pre
  >{{ user | json }}
</pre>
// app/async-validation/sign-up-form-template/sign-up-form-template.component.ts

import { Component } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { finalize } from 'rxjs/operators';
import { User } from '../user';
import { UserService } from '../user.service';

@Component({
  selector: 'app-sign-up-form-template',
  templateUrl: './sign-up-form-template.component.html',
})
export class SignUpFormTemplateComponent {
  readonly user: User = {
    id: 0,
    username: '',
    password: '',
  };
  loading = false;

  constructor(
    private readonly userService: UserService,
    private readonly snackBar: MatSnackBar
  ) {}

  submit(): void {
    this.loading = true;
    this.userService
      .save(this.user)
      .pipe(finalize(() => (this.loading = false)))
      .subscribe(({ id }) => {
        this.snackBar.open(`User saved with ID #${id}`, 'Close');
      });
  }
}
// app/async-validation/user.service.ts

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { User } from './user';

export interface UserSaveResponse {
  id: number;
}

@Injectable({
  providedIn: 'root',
})
export class UserService {
  isUsernameAvailable(username: string): Observable<boolean> {
    console.log('Is username available?', username);
    // Let's say that all usernames with a dot are taken
    return of(username.indexOf('.') === -1 ? true : false).pipe(delay(1000));
  }

  save(user: User): Observable<UserSaveResponse> {
    console.log('Save:', user);
    return of({
      id: 1,
    }).pipe(delay(1000));
  }
}