skip to content

In Angular reactive forms, how do you write an AsyncValidatorFn that checks whether a username is taken, and what does the PENDING status mean?

level: middleimportance: should knowfreq 55%

answer

  1. returns something that arrives later
  2. Observable or Promise of errors
  3. runs only after sync validators pass
  4. status PENDING, pending is true

basics

~10 s

An AsyncValidatorFn takes the control and returns an Observable or Promise of ValidationErrors or null. Angular runs it only when the sync validators pass, and the control's status is PENDING until the result arrives.

solid answer

~40 s

An `AsyncValidatorFn` has the same input as a sync validator, an `AbstractControl`, but returns an `Observable` or `Promise` of `ValidationErrors | null`. For a username check it calls a service and maps the answer: `{usernameTaken: true}` or `null`. You attach it with the `asyncValidators` option, or the third constructor argument. Angular runs async validators only when the sync ones pass, so `required` and `minLength` guard the server. While the check is in flight the control's `status` is `PENDING` and `pending` is `true`, and that status bubbles up so the parent form is `PENDING` too; a submit button should treat pending as not ready. If the value changes before the answer arrives, Angular unsubscribes from the previous validation and starts a new one. Return an observable that completes, as the docs require.

go deeper

for a junior

Recall that an async validator returns an Observable or Promise of errors or null, and that the control shows PENDING while it runs.

for a middle

Explain the ordering: stale check unsubscribed, sync validators first, async only when they pass, and PENDING propagating to the parent form.

for a senior

Guard the submit button against PENDING, map failures to a definite result so nothing stays pending, and keep the server as the final uniqueness check.

for a principal

Weigh live availability checks against their backend cost and privacy, since a public availability endpoint also lets anyone enumerate accounts.

## The contract In Angular's `@angular/forms`, an **async validator** is a function with the `AsyncValidatorFn` shape: - **Input**: the `AbstractControl` being validated. - **Output**: a `Promise` or `Observable` that emits `ValidationErrors` (invalid) or `null` (valid). A class can implement the equivalent `AsyncValidator` interface with a `validate()` method, which is what template-driven forms register through `NG_ASYNC_VALIDATORS`. In reactive forms the function form is the usual choice. The documented rule is that an observable must be **finite**: it has to complete. This is not only a convention: when async validators are passed as an array, even an array of one, Angular combines them with `forkJoin`, which emits only after every observable has completed, so a stream that never completes leaves the control stuck. ## A username-availability validator ```ts import {HttpClient} from '@angular/common/http'; import {AbstractControl, AsyncValidatorFn, ValidationErrors} from '@angular/forms'; import {Observable} from 'rxjs'; import {map} from 'rxjs/operators'; export function usernameAvailable(http: HttpClient): AsyncValidatorFn { return (control: AbstractControl): Observable<ValidationErrors | null> => http .get<{available: boolean}>(`/api/usernames/${encodeURIComponent(control.value)}`) .pipe(map((res) => (res.available ? null : {usernameTaken: true}))); } ``` A factory that receives its dependencies keeps the validator a plain function and easy to test. In a component you call it with an injected `HttpClient` and pass the result through the `asyncValidators` option: ```ts username = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3)], asyncValidators: [usernameAvailable(this.http)], }); ``` ## When Angular runs it On each value change Angular: 1. Cancels any async validation still running for this control by **unsubscribing** from it. 2. Runs the sync validators and computes `errors`. 3. If the result is valid, sets `status` to `PENDING` and subscribes to the async validator. 4. When the validator emits, stores the result in `errors` and recomputes `status` to `VALID` or `INVALID`. Two consequences follow. Async validation is **skipped** whenever a sync validator fails, so a two-character username never reaches the server. And because an outdated check is unsubscribed, an answer for an old value is not written over the current one. ## What `PENDING` means `PENDING` is one of four mutually exclusive values of `control.status`: `VALID`, `INVALID`, `PENDING`, `DISABLED`. It means "a verdict is on its way". | Property | During the check | |---|---| | `username.status` | `PENDING` | | `username.pending` | `true` | | `username.valid` / `invalid` | both `false` | | parent `form.status` | `PENDING` | | CSS class on the input | `ng-pending` | The row that catches people is the second-to-last: a group whose child is pending is itself `PENDING` (unless the group's own validators have already failed, which makes it `INVALID`), so `form.valid` is `false` and `form.invalid` is also `false`. A submit button bound to `[disabled]="form.invalid"` is therefore **enabled** during the check. Use `form.invalid || form.pending`, or `!form.valid`. ## Showing it to the user ```html @if (username.pending) { <p>Checking availability...</p> } @else if (username.hasError('usernameTaken')) { <p>That username is taken.</p> } ``` ## Testing the validator Because the factory takes its dependency as an argument, a unit test can pass a stub whose `get()` returns `of({available: false})`, attach the validator to a `FormControl`, set a value and assert the result: 1. Right after `setValue('ada')`, with a stub that answers later, `status` is `PENDING`. 2. Once the stub's observable emits, `status` is `INVALID` and `hasError('usernameTaken')` is `true`. 3. With `setValue('')`, the stub is never called, which proves the sync-first ordering. A test like this pins down the behaviour interviewers ask about, and it catches the regression where someone moves the async validator into the sync list. ## Pitfalls - Returning an `Observable` that never completes, such as a `valueChanges` stream, which the documentation forbids and which blocks a combined validator. - Returning `EMPTY` from an error handler: it completes without emitting, so the control never leaves `PENDING`. Map failures to `null` or to a specific error instead. - Putting an async validator among the **sync** validators: Angular stores the returned Observable object as if it were an errors value, so the control typically reports `INVALID` for every input. The opposite mistake, a sync validator in the async slot, fails loudly in development mode with an error saying an async validator must return a Promise or Observable. - Treating the client-side check as authoritative: two users can pick the same name between check and submit, so the server must still enforce uniqueness. An interviewer is listening for four things: the return type, the sync-first ordering, the meaning of `PENDING` for the whole form, and the cancellation of stale checks.

  • Why can a submit button bound to [disabled]="form.invalid" be clickable while the username check is running?
    While a child's async validator is in flight, the form's status is `PENDING`, and `PENDING` is neither `VALID` nor `INVALID`, so `form.invalid` is `false`. Bind to `form.invalid || form.pending`, or to `!form.valid`, so the button stays disabled until the verdict arrives.
  • Does Angular call the async validator when the value fails Validators.required?
    No. Angular runs sync validators first and only starts async validation when they pass. An empty or too-short username is reported as `INVALID` by the sync validators without any request being made.

saying these in an interview costs you the question

  • An async validator returns ValidationErrors directly, just later.
  • Async and sync validators run in parallel on every change.
  • form.invalid is true while a field is PENDING.
  • An old check's answer can overwrite the result for the newer value.
  • Returning EMPTY on error safely marks the field valid.