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:
| State | Meaning |
|---|---|
VALID | All sync and async validators passed |
INVALID | A sync validator failed, or async returned errors |
PENDING | Sync 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
requiredandminLengthas synchronous validators. - Always debounce input (via
timer+switchMap, orupdateOn: 'blur') so you don’t hammer the API on every keystroke. - Use
switchMapto cancel stale in-flight requests; never usemergeMap, which lets outdated responses win. - Complete the observable with
first()(or return aPromise) so Angular can transition the control out ofpending. - Handle network failures with
catchError— decide deliberately whether an error should block the user or fail open. - Disable submit buttons while
form.pendingis true to avoid submitting before validation resolves. - Provide async validators via DI (
inject()inside a factory) so they remain testable and tree-shakeable.