Angular Reactive Forms Validation: Validators, Async Checks, and Accessible Errors

Angular Reactive Forms model a form as an immutable tree of FormControl, FormGroup, and FormArray instances whose validity is computed by pure validator functions — the same pass/fail/message contract the browser exposes through native constraint checking, lifted into a reactive, testable object graph. This guide covers FormBuilder, the built-in Validators, custom synchronous and asynchronous validators, group-level cross-field rules, ControlValueAccessor for custom controls, and accessible error templates, mapping each back to the native Constraint Validation API Deep Dive and the UX Patterns & Error State Design approaches.

Why Reactive Forms Mirror Native Constraint Validation

A native input exposes validity.valueMissing, validity.patternMismatch, and a validationMessage. Angular’s reactive model exposes the same information as control.errors — an object keyed by error name ({ required: true }, { pattern: { ... } }) — plus control.valid, control.touched, and control.dirty. The parallel is exact: a validator function is the framework analog of a constraint attribute, and control.errors is the framework analog of ValidityState.

The house pattern on this site is <form novalidate> plus a manual checkValidity()/reportValidity() gate. Angular’s [formGroup] directive applies novalidate automatically, and form.valid is the framework-native version of checkValidity(): a synchronous boolean you check before dispatching the payload. You stay in control of when and how errors render, exactly as the native pattern intends.

Angular reactive forms validation flow A FormGroup contains FormControls. Each control runs synchronous validators then asynchronous validators, producing a status of valid, invalid, or pending. A group-level validator compares fields. The errors object drives the accessible error template. FormGroup + group validator FormControl sync validators FormControl async validators VALID INVALID + errors PENDING (async) aria template
Each control runs synchronous then asynchronous validators; the resulting status and errors object drive the accessible error template, while a group-level validator compares sibling fields.

Reactive Forms Versus Template-Driven Forms

Angular ships two form paradigms. Template-driven forms (ngModel + directives) push validation into the template and are closest to plain native validation — fine for trivial forms but awkward once rules become dynamic. Reactive forms invert the control: the form model is defined in TypeScript as an explicit, synchronously available object tree, and the template merely binds to it. This is the right choice for anything with custom validators, async checks, cross-field rules, or dynamic controls, because the validity of every node is computable and testable without touching the DOM.

Concern Template-driven Reactive
Source of truth Template directives TypeScript model
Custom sync validators Awkward ValidatorFn, first-class
Async validators Limited AsyncValidatorFn, first-class
Cross-field rules Hard Group-level validator
Unit testability Needs a fixture Pure function tests
Dynamic controls Difficult FormArray

Everything below uses reactive forms, the paradigm built for production-grade validation.

Prerequisites

Requirement Minimum Notes
Angular 16+ Standalone components, typed forms
ReactiveFormsModule Imported in the component or module
@angular/forms 16+ FormBuilder, Validators, AsyncValidatorFn
RxJS 7+ Observables for async validators and debounce
TypeScript 5.0+ NonNullableFormBuilder for typed controls

Core API Reference

Symbol Layer Purpose
FormBuilder / NonNullableFormBuilder Construction Concise typed creation of groups and controls
FormGroup Container Aggregates child controls; hosts cross-field validators
FormControl<T> Leaf Holds one value plus its validators and status
FormArray Container Ordered list of controls
Validators.required / .email / .minLength / .pattern Sync validator Built-in rules, analogous to native constraint attributes
ValidatorFn Type (control) => ValidationErrors | null
AsyncValidatorFn Type (control) => Observable<ValidationErrors | null> | Promise<...>
control.errors State Error object, analog of ValidityState
control.statusChanges Observable Emits VALID / INVALID / PENDING
updateOn: 'blur' | 'submit' Option When validators run — controls feedback timing
ControlValueAccessor Interface Bridges a custom component to the forms API

Step 1 — Building a Typed Form with FormBuilder

NonNullableFormBuilder produces controls whose value type is non-nullable, eliminating the string | null noise of the default builder.

// registration-form.component.ts
import { Component, inject } from '@angular/core';
import {
  NonNullableFormBuilder,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';

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

  // Each Validators entry is the framework analog of an HTML constraint attribute.
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    username: [
      '',
      [
        Validators.required,
        Validators.minLength(3),
        Validators.maxLength(20),
        Validators.pattern(/^[a-zA-Z0-9_]+$/),
      ],
    ],
    age: [0, [Validators.required, Validators.min(18)]],
  });

  onSubmit(): void {
    // form.valid is the framework-native checkValidity() gate.
    if (this.form.invalid) {
      this.form.markAllAsTouched(); // reveal errors that were never touched
      this.focusFirstInvalid();
      return;
    }
    this.submit(this.form.getRawValue()); // fully typed payload
  }

  private focusFirstInvalid(): void {
    const firstInvalid =
      document.querySelector<HTMLElement>('.ng-invalid[formControlName]');
    firstInvalid?.focus();
  }
}

markAllAsTouched() mirrors the native behavior where errors surface on submit even for fields the user never visited. The focusFirstInvalid() helper implements the focus-management requirement detailed in Focus Management & Keyboard Navigation — Angular does not move focus for you.

Step 2 — Custom Synchronous Validators

A ValidatorFn returns null when valid or a keyed ValidationErrors object when invalid. The key names the error so the template can select a message.

// validators/no-reserved-words.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

const RESERVED = new Set(['admin', 'root', 'system']);

export function noReservedWords(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = String(control.value ?? '').toLowerCase().trim();
    if (RESERVED.has(value)) {
      // The error key 'reserved' is selected in the template, like a ValidityState flag.
      return { reserved: { value } };
    }
    return null;
  };
}

Attach it alongside the built-ins: username: ['', [Validators.required, noReservedWords()]]. The pattern is identical to reading a granular flag off ValidityState; here you read control.errors?.['reserved'] instead of input.validity.customError.

Step 3 — Custom Asynchronous Validators

An AsyncValidatorFn returns an Observable or Promise that emits the same ValidationErrors | null. While it is pending, control.status is 'PENDING'. This is the framework-native form of an asynchronous server check, and it carries the same stale-response hazard — solved here with RxJS switchMap, which cancels the previous inner subscription when a new value arrives.

// validators/username-available.validator.ts
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError, first } from 'rxjs/operators';

export function usernameAvailableValidator(): AsyncValidatorFn {
  const http = inject(HttpClient);

  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = String(control.value ?? '').trim();
    if (value.length < 3) return of(null); // let sync validators own short values

    // timer(300) debounces; switchMap cancels the prior request on new input.
    return timer(300).pipe(
      switchMap(() =>
        http.get<{ available: boolean }>(`/api/username-available?u=${encodeURIComponent(value)}`),
      ),
      map(({ available }) => (available ? null : { taken: true })),
      catchError(() => of({ unverified: true })), // network failure → distinct error
      first(),
    );
  };
}

Register async validators in the third argument slot so they run after the synchronous ones pass:

username: this.fb.control('', {
  validators: [Validators.required, Validators.minLength(3), noReservedWords()],
  asyncValidators: [usernameAvailableValidator()],
  updateOn: 'blur', // avoid a request per keystroke; validate on blur
}),

updateOn: 'blur' is the timing lever; combined with the 300ms debounce it keeps request volume sane, matching the real-time-feedback guidance in the UX Patterns & Error State Design approaches.

Step 4 — Group-Level Cross-Field Validators

A validator attached to the FormGroup receives the group and can compare sibling controls — the canonical use is matching a password and its confirmation, covered in depth in Cross-Field Validation Strategies.

// validators/passwords-match.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export const passwordsMatch: ValidatorFn = (
  group: AbstractControl,
): ValidationErrors | null => {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  if (!confirm) return null; // do not flag before the user fills confirm
  return password === confirm ? null : { passwordMismatch: true };
};

Attach it as the group’s validator: this.fb.group({ ... }, { validators: passwordsMatch }). The error lands on the group (form.errors?.['passwordMismatch']), so the template selects it from the group, not an individual control.

Step 5 — ControlValueAccessor for Custom Controls

When you wrap a non-native widget (a custom toggle, a date picker), implement ControlValueAccessor so Angular’s validation engine treats it like any control — preserving the native-equivalent validity contract through a custom UI.

// star-rating.component.ts
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  standalone: true,
  template: `
    <div role="radiogroup" aria-label="Rating">
      <button
        *ngFor="let star of [1, 2, 3, 4, 5]"
        type="button"
        role="radio"
        [attr.aria-checked]="star === value"
        (click)="select(star)"
        (blur)="onTouched()"
      >★</button>
    </div>
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StarRatingComponent),
      multi: true,
    },
  ],
})
export class StarRatingComponent implements ControlValueAccessor {
  value = 0;
  onChange: (v: number) => void = () => {};
  onTouched: () => void = () => {};

  writeValue(v: number): void { this.value = v ?? 0; }
  registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }

  select(star: number): void {
    this.value = star;
    this.onChange(star); // pushes value into the FormControl, triggering validation
  }
}

Once registered, Validators.min(1) on the control validates the custom widget exactly as it would a native <input>.

Step 6 — Accessible Error Templates

The template selects an error message by key and binds the accessibility attributes that satisfy WCAG SC 3.3.1.

<!-- registration-form.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div class="form-group">
    <label for="username">Username</label>
    <input
      id="username"
      type="text"
      formControlName="username"
      [attr.aria-invalid]="form.controls.username.touched && form.controls.username.invalid"
      [attr.aria-describedby]="form.controls.username.invalid ? 'username-error' : null"
    />
    @if (form.controls.username.touched && form.controls.username.errors; as errors) {
      <p id="username-error" class="error" role="alert">
        @if (errors['required']) { Username is required. }
        @else if (errors['minlength']) { At least 3 characters. }
        @else if (errors['pattern']) { Letters, numbers and underscores only. }
        @else if (errors['reserved']) { That username is reserved. }
        @else if (errors['taken']) { That username is already taken. }
        @else if (errors['unverified']) { Could not verify username, please retry. }
      </p>
    }
    @if (form.controls.username.pending) {
      <p class="hint" aria-live="polite">Checking availability…</p>
    }
  </div>

  <button type="submit">Create Account</button>
</form>

The aria-describedby only points at the error node when an error exists, role="alert" announces it, and the pending branch uses aria-live="polite" for the async status — the same status-message pattern (WCAG SC 4.1.3) you would build manually over native validation.

Dynamic Controls with FormArray

Repeating sections — multiple email addresses, a list of tags — use FormArray, where each entry is itself a control or group carrying its own validators. The array’s validity is the conjunction of its children, so the parent form.valid gate still works unchanged.

emails = this.fb.array<FormControl<string>>([
  this.fb.control('', [Validators.required, Validators.email]),
]);

addEmail(): void {
  this.emails.push(this.fb.control('', [Validators.required, Validators.email]));
}

removeEmail(index: number): void {
  this.emails.removeAt(index);
}

In the template, iterate the array’s controls and bind each error template per row. Because every child control exposes the same errors/touched/pending surface, the accessible rendering pattern from Step 6 applies identically to dynamic rows — no special-casing required.

Testing the Validation Logic

Validators are pure functions, so they test in isolation without rendering a component — the framework analog of unit-testing a constraint check against a mocked ValidityState.

// passwords-match.validator.spec.ts
import { FormBuilder } from '@angular/forms';
import { passwordsMatch } from './validators/passwords-match.validator';

describe('passwordsMatch', () => {
  const fb = new FormBuilder();

  it('returns null when fields agree', () => {
    const group = fb.group(
      { password: 'abc12345', confirmPassword: 'abc12345' },
      { validators: passwordsMatch() },
    );
    expect(group.errors).toBeNull();
  });

  it('sets passwordMismatch on the group when fields differ', () => {
    const group = fb.group(
      { password: 'abc12345', confirmPassword: 'xyz' },
      { validators: passwordsMatch() },
    );
    expect(group.errors?.['passwordMismatch']).toBe(true);
  });
});

For async validators, use fakeAsync/tick to advance past the debounce timer and HttpTestingController to stub the server response, asserting the control transitions PENDING → INVALID with the expected error key. End-to-end, a Playwright run should assert aria-invalid, the role="alert" message text, and focus movement on a failed submit — the same accessibility contract you would verify for native validation.

Common Gotchas

1. Async validator fires on every keystroke. Without updateOn: 'blur' and a debounce, you flood the server. Add both.

// Before — a request per character
asyncValidators: [usernameAvailableValidator()]
// After — debounced inside the validator + updateOn blur
{ asyncValidators: [usernameAvailableValidator()], updateOn: 'blur' }

2. Errors never appear because the field is untouched. On submit, call form.markAllAsTouched() before reading validity, or errors stay hidden.

3. Group-level error read from a child control. A passwordMismatch error lives on the group. Reading form.controls.confirmPassword.errors returns null; read form.errors instead.

4. mergeMap instead of switchMap in async validators. mergeMap lets stale responses resolve out of order and overwrite the current state. Use switchMap so a new value cancels the prior request.

5. Forgetting multi: true on NG_VALUE_ACCESSOR. Without it, Angular replaces the provider array and your custom control silently fails to bind.

Browser Compatibility

Capability Chrome Firefox Safari Notes
Angular reactive forms 64+ 60+ 12+ Validation runs in JS, engine-agnostic
AbortController (if using fetch) 66+ 57+ 12.1+ RxJS switchMap is the usual cancellation path
aria-live / role="alert" All All All Core a11y contract
@if / @for control flow Requires Angular 17+; use *ngIf/*ngFor on 16

Frequently Asked Questions

How is control.errors related to native ValidityState?

They are direct analogs. Native validation exposes flags like valueMissing on input.validity; Angular exposes a keyed object like { required: true } on control.errors. In both cases you read the specific key to choose a message, and form.valid plays the role of checkValidity().

When should I use updateOn: 'blur' versus the default?

Use 'blur' for fields with expensive async validators so you validate once the user leaves the field rather than on every keystroke. Keep the default 'change' for cheap synchronous rules where immediate feedback helps. 'submit' defers all validation to form submission.

Why does my password-match error not show on the confirm field?

Group-level validators set the error on the FormGroup, not on a child control. Read form.errors?.['passwordMismatch'] and render the message under the confirm field, or copy the error onto the control with setErrors if you prefer it attached there.

How do I prevent stale async validation responses?

Use RxJS switchMap inside the AsyncValidatorFn. It cancels the previous inner request when a new value arrives, so a slow early response can never overwrite a fast later one — the same guarantee AbortController gives a raw fetch-based check.

← Back to Framework Integration Patterns

Explore This Section