CPCODELAB
Angular

Reactive Forms: Validators, Groups, and Arrays

13 min read

Going Deeper with Reactive Forms

Reactive forms shine when you need programmatic control — dynamic fields, cross-field validation, or complex nested structures. FormGroup, FormArray, and custom ValidatorFn functions give you full control from the TypeScript class.

Cross-Field Validation with a Custom Validator

ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Custom validator: password and confirmPassword must match
export const passwordMatchValidator: ValidatorFn = (
  group: AbstractControl
): ValidationErrors | null => {
  const pw = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  return pw === confirm ? null : { passwordMismatch: true };
};
ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormArray, Validators } from '@angular/forms';
import { passwordMatchValidator } from './password-match.validator';

@Component({
  selector: 'app-registration',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="email" type="email" placeholder="Email" />
      <input formControlName="password" type="password" placeholder="Password" />
      <input formControlName="confirmPassword" type="password" placeholder="Confirm" />
      @if (form.hasError('passwordMismatch')) {
        <p class="error">Passwords do not match.</p>
      }

      <h4>Phone numbers</h4>
      <div formArrayName="phones">
        @for (ctrl of phones.controls; track $index) {
          <input [formControlName]="$index" placeholder="Phone" />
        }
      </div>
      <button type="button" (click)="addPhone()">Add phone</button>
      <button type="submit" [disabled]="form.invalid">Register</button>
    </form>
  `
})
export class RegistrationComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group(
    {
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required],
      phones: this.fb.array([this.fb.control('')])
    },
    { validators: passwordMatchValidator }
  );

  get phones() {
    return this.form.get('phones') as FormArray;
  }

  addPhone() {
    this.phones.push(this.fb.control(''));
  }

  onSubmit() {
    if (this.form.valid) console.log(this.form.value);
  }
}

Pass a group-level validator as the second argument to fb.group({ ... }, { validators: myFn }). The validator receives the entire FormGroup, so it can compare sibling controls.

Async Validators

Use AsyncValidatorFn when validation requires a server round-trip (e.g. checking username availability). Return an Observable or Promise that resolves to ValidationErrors | null. Pass it as the third argument to FormControl.

ts
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, catchError, of, debounceTime, switchMap, first } from 'rxjs';

export function uniqueUsernameValidator(): AsyncValidatorFn {
  const http = inject(HttpClient);
  return (control: AbstractControl) =>
    control.valueChanges.pipe(
      debounceTime(400),
      switchMap(value =>
        http.get<{ taken: boolean }>(`/api/check-username?q=${value}`)
      ),
      map(res => (res.taken ? { usernameTaken: true } : null)),
      catchError(() => of(null)),
      first()
    );
}
Ready to test yourself?
Practice Angular— quiz & coding exercises