Official Documentation View Source on Github

Highlights

  1. Define the validator by implementing AsyncValidator.
  2. The validator is debounced with delay to avoid doing too many HTTP requests.
  3. Set the custom validators with FormBuilder.
  4. Access the validation errors through the field getter.
  5. 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.ts

import { Injectable } from '@angular/core';
import {
  AbstractControl,
  AsyncValidator,
  ValidationErrors,
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { delay, filter, map, switchMap } from 'rxjs/operators';
import { UserService } from './user.service';

// For more context see: https://stackoverflow.com/a/62662296
@Injectable({
  providedIn: 'root',
})
export class UniqueUsernameValidator implements AsyncValidator {
  constructor(private readonly userService: UserService) {}

  validate(
    control: AbstractControl
  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
    return of(control.value).pipe(
      filter((username) => !!username),
      delay(500),
      switchMap((username) =>
        this.userService
          .isUsernameAvailable(username)
          .pipe(
            map((available) =>
              available ? null : { uniqueUsername: { value: control.value } }
            )
          )
      )
    );
  }
}
<!-- app/async-validation/sign-up-form-reactive/sign-up-form-reactive.component.html -->

<h1>Sign Up Form (reactive)</h1>

<mat-card>
  <form [formGroup]="signUpForm" (ngSubmit)="submit()">
    <mat-form-field>
      <mat-label>Username</mat-label>
      <input matInput name="username" formControlName="username" />
      <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
        type="password"
        name="password"
        formControlName="password"
      />
      <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.valid"
      >
        Submit
      </button>
    </div>
  </form>
</mat-card>

<h2>User (JSON)</h2>

<pre
  >{{ signUpForm.value | json }}
</pre>
// app/async-validation/sign-up-form-reactive/sign-up-form-reactive.component.ts

import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { finalize } from 'rxjs/operators';
import { UniqueUsernameValidator } from '../unique-username-validator';
import { User } from '../user';
import { UserService } from '../user.service';

@Component({
  selector: 'app-sign-up-form-reactive',
  templateUrl: './sign-up-form-reactive.component.html',
})
export class SignUpFormReactiveComponent {
  readonly signUpForm = this.formBuilder.group({
    username: ['', Validators.required, [this.uniqueUsernameValidator]],
    password: ['', Validators.required],
  });
  loading = false;

  get username() {
    return this.signUpForm.get('username');
  }

  get password() {
    return this.signUpForm.get('password');
  }

  constructor(
    private readonly formBuilder: FormBuilder,
    private readonly uniqueUsernameValidator: UniqueUsernameValidator,
    private readonly userService: UserService,
    private readonly snackBar: MatSnackBar
  ) {}

  submit(): void {
    const user: User = this.signUpForm.value;

    this.loading = true;
    this.userService
      .save(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));
  }
}