Skip to content
Angular ng forms 4 min read

Async Validators

Some validation rules can’t be answered on the client alone. Checking whether a username is already taken, whether an email is registered, or whether a coupon code is valid all require a round trip to the server. Angular handles these cases with async validators: functions that return an Observable or Promise of validation errors. While an async validator is pending, the control enters a pending state, letting you show spinners and block submission until the answer arrives.

How async validation works

An async validator implements AsyncValidatorFn. It receives the AbstractControl and returns either an Observable<ValidationErrors | null> or a Promise<ValidationErrors | null> that resolves once — emitting an errors object when the value is invalid, or null when it’s valid.

Angular runs async validators only after every synchronous validator passes. This is a deliberate optimization: there’s no point asking the server whether a username is unique if it’s empty or too short. The control’s lifecycle moves through three relevant states:

StateMeaning
VALIDAll sync and async validators passed
INVALIDA sync validator failed, or async returned errors
PENDINGSync passed; async validators are still running

You read the in-flight state with control.pending, and the control’s statusChanges observable emits each transition.

Async validators receive a control whose value can change rapidly as the user types. Always debounce and cancel stale requests, or you’ll flood your API and risk showing results for an outdated value.

A unique-username validator

The classic example is verifying a username isn’t already registered. Below is a functional async validator that calls a UserService and maps the response to a ValidationErrors object. switchMap cancels the previous request when a new value arrives, and first() completes the observable so Angular knows validation is done.

import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, first, catchError } from 'rxjs/operators';
import { UserService } from './user.service';

export function uniqueUsernameValidator(): AsyncValidatorFn {
  const users = inject(UserService);

  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) {
      return of(null);
    }

    // 400ms debounce: wait for typing to settle before hitting the API.
    return timer(400).pipe(
      switchMap(() => users.isUsernameTaken(control.value)),
      map((taken) => (taken ? { usernameTaken: true } : null)),
      catchError(() => of(null)), // don't block the user on a network error
      first()
    );
  };
}

The service simply wraps an HTTP call:

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);

  isUsernameTaken(username: string): Observable<boolean> {
    return this.http.get<boolean>(`/api/users/exists`, { params: { username } });
  }
}

Wiring it into a reactive form

Async validators are passed as the third argument to a FormControl (sync validators are the second). Calling uniqueUsernameValidator() inside the injection context of a component picks up inject() correctly.

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { uniqueUsernameValidator } from './unique-username.validator';

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup.component.html',
})
export class SignupComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    username: this.fb.control('', {
      validators: [Validators.required, Validators.minLength(3)],
      asyncValidators: [uniqueUsernameValidator()],
      updateOn: 'change',
    }),
  });

  get username() {
    return this.form.controls.username;
  }
}

The template uses the new control flow to show a spinner while pending and the error once resolved:

<form [formGroup]="form">
  <label for="username">Username</label>
  <input id="username" formControlName="username" />

  @if (username.pending) {
    <small class="hint">Checking availability…</small>
  } @else if (username.hasError('usernameTaken')) {
    <small class="error">That username is already taken.</small>
  } @else if (username.valid && username.value) {
    <small class="ok">Username is available.</small>
  }
</form>

Output:

Type "ada"   → "Checking availability…"  → "Username is available."
Type "admin" → "Checking availability…"  → "That username is already taken."

Class-based async validators

When a validator needs heavier dependencies or you prefer DI tokens, implement the AsyncValidator interface. Register it as a provider against NG_ASYNC_VALIDATORS for template-driven forms, or instantiate it directly for reactive forms.

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

@Injectable({ providedIn: 'root' })
export class UniqueUsernameValidator implements AsyncValidator {
  private users = inject(UserService);

  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    return this.users.isUsernameTaken(control.value).pipe(
      map((taken) => (taken ? { usernameTaken: true } : null)),
      catchError(() => of(null))
    );
  }
}

Controlling when validation runs

Async validators that hit the network are expensive, so set updateOn: 'blur' to validate only when the user leaves the field, rather than on every keystroke. This pairs well with — or replaces — manual debouncing.

username: this.fb.control('', {
  asyncValidators: [uniqueUsernameValidator()],
  updateOn: 'blur',
});

To gate a submit button on pending state, combine valid and pending:

<button type="submit" [disabled]="form.invalid || form.pending">Sign up</button>

Best Practices

  • Run async validators only after sync validators pass — keep cheap checks like required and minLength as synchronous validators.
  • Always debounce input (via timer + switchMap, or updateOn: 'blur') so you don’t hammer the API on every keystroke.
  • Use switchMap to cancel stale in-flight requests; never use mergeMap, which lets outdated responses win.
  • Complete the observable with first() (or return a Promise) so Angular can transition the control out of pending.
  • Handle network failures with catchError — decide deliberately whether an error should block the user or fail open.
  • Disable submit buttons while form.pending is true to avoid submitting before validation resolves.
  • Provide async validators via DI (inject() inside a factory) so they remain testable and tree-shakeable.
Last updated June 14, 2026
Was this helpful?