Forms

Form Validation in Angular

Master Angular form validation — built-in validators, custom validators, async validators, cross-field validation, and dynamic error messages.

Overview

Angular provides a robust validation system for both template-driven and reactive forms. Validation ensures user input meets your requirements before processing.

Built-in Validators

For Reactive Forms

typescript
import { Validators } from '@angular/forms';

this.fb.group({
  name:     ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
  email:    ['', [Validators.required, Validators.email]],
  age:      [null, [Validators.required, Validators.min(18), Validators.max(120)]],
  website:  ['', Validators.pattern(/^https?:\/\/.+/)],
  password: ['', [Validators.required, Validators.minLength(8)]],
});

Validator Reference

ValidatorPurposeExample
Validators.requiredMust have a valueNon-empty field
Validators.requiredTrueMust be trueCheckbox agreement
Validators.emailValid email formatuser@domain.com
Validators.min(n)Minimum number valueAge ≥ 18
Validators.max(n)Maximum number valueQuantity ≤ 100
Validators.minLength(n)Minimum string lengthName ≥ 2 chars
Validators.maxLength(n)Maximum string lengthBio ≤ 500 chars
Validators.pattern(regex)matches regexPhone format

Displaying Error Messages

Helper Method

typescript
export class MyFormComponent {
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
  });

  // Helper to check if a control has a specific error
  hasError(controlName: string, errorName: string): boolean {
    const control = this.form.get(controlName);
    return control ? control.hasError(errorName) && control.touched : false;
  }
}
html
<div>
  <label>Email</label>
  <input formControlName="email" type="email">
  @if (hasError('email', 'required')) {
    <small class="error">Email is required</small>
  }
  @if (hasError('email', 'email')) {
    <small class="error">Please enter a valid email</small>
  }
</div>

<div>
  <label>Password</label>
  <input formControlName="password" type="password">
  @if (hasError('password', 'required')) {
    <small class="error">Password is required</small>
  }
  @if (hasError('password', 'minlength')) {
    <small class="error">
      Password must be at least
      {{ form.get('password')?.errors?.['minlength']?.requiredLength }} characters
    </small>
  }
</div>

Custom Validators

Synchronous Validator

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

// Validator factory function
export function forbiddenNameValidator(forbidden: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const isForbidden = control.value?.toLowerCase() === forbidden.toLowerCase();
    return isForbidden ? { forbiddenName: { value: control.value } } : null;
  };
}

// Simple validator function
export function noWhitespaceValidator(control: AbstractControl): ValidationErrors | null {
  const isWhitespace = (control.value || '').trim().length === 0;
  return isWhitespace ? { whitespace: true } : null;
}

// Strong password validator
export function strongPasswordValidator(control: AbstractControl): ValidationErrors | null {
  const value = control.value;
  if (!value) return null;

  const errors: ValidationErrors = {};
  if (!/[A-Z]/.test(value)) errors['missingUppercase'] = true;
  if (!/[a-z]/.test(value)) errors['missingLowercase'] = true;
  if (!/[0-9]/.test(value)) errors['missingNumber'] = true;
  if (!/[!@#$%^&*]/.test(value)) errors['missingSpecial'] = true;

  return Object.keys(errors).length ? errors : null;
}

Using Custom Validators

typescript
form = this.fb.group({
  username: ['', [
    Validators.required,
    noWhitespaceValidator,
    forbiddenNameValidator('admin'),
  ]],
  password: ['', [
    Validators.required,
    Validators.minLength(8),
    strongPasswordValidator,
  ]],
});
html
<div>
  <label>Password</label>
  <input formControlName="password" type="password">

  @if (form.get('password')?.touched && form.get('password')?.invalid) {
    <div class="password-requirements">
      <p [class.valid]="!form.get('password')?.hasError('missingUppercase')">
        ✓ Uppercase letter
      </p>
      <p [class.valid]="!form.get('password')?.hasError('missingLowercase')">
        ✓ Lowercase letter
      </p>
      <p [class.valid]="!form.get('password')?.hasError('missingNumber')">
        ✓ Number
      </p>
      <p [class.valid]="!form.get('password')?.hasError('missingSpecial')">
        ✓ Special character (!@#$%^&*)
      </p>
    </div>
  }
</div>

Cross-Field Validation

Validate relationships between fields using group-level validators:

Password Match Validator

typescript
export function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirmPassword = group.get('confirmPassword')?.value;

  if (password !== confirmPassword) {
    return { passwordMismatch: true };
  }
  return null;
}
typescript
form = this.fb.group({
  password: ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', Validators.required],
}, {
  validators: [passwordMatchValidator],
});
html
<form [formGroup]="form">
  <input formControlName="password" type="password" placeholder="Password">
  <input formControlName="confirmPassword" type="password" placeholder="Confirm">

  @if (form.hasError('passwordMismatch') && form.get('confirmPassword')?.touched) {
    <small class="error">Passwords do not match</small>
  }
</form>

Date Range Validator

typescript
export function dateRangeValidator(group: AbstractControl): ValidationErrors | null {
  const start = group.get('startDate')?.value;
  const end = group.get('endDate')?.value;

  if (start && end && new Date(start) >= new Date(end)) {
    return { invalidDateRange: true };
  }
  return null;
}

Async Validators

Async validators perform validation that requires a backend call (e.g., checking username availability):

typescript
import { AsyncValidatorFn } from '@angular/forms';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs';

export function uniqueEmailValidator(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return control.valueChanges.pipe(
      debounceTime(400),
      switchMap(value => userService.checkEmailExists(value)),
      map(exists => exists ? { emailTaken: true } : null),
      catchError(() => of(null)),
      first(),
    );
  };
}

Simpler Approach

typescript
export function uniqueUsernameValidator(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return userService.checkUsername(control.value).pipe(
      map(isTaken => isTaken ? { usernameTaken: true } : null),
      catchError(() => of(null)),
    );
  };
}

Using Async Validators

typescript
export class RegisterComponent {
  private userService = inject(UserService);

  form = this.fb.group({
    username: ['',
      [Validators.required, Validators.minLength(3)],      // sync validators
      [uniqueUsernameValidator(this.userService)],           // async validators
    ],
    email: ['', [Validators.required, Validators.email]],
  });
}
html
<input formControlName="username" placeholder="Username">
@if (form.get('username')?.pending) {
  <small>Checking availability...</small>
}
@if (form.get('username')?.hasError('usernameTaken')) {
  <small class="error">Username is already taken</small>
}

Reusable Error Message Component

typescript
@Component({
  selector: 'app-field-error',
  standalone: true,
  template: `
    @if (control?.invalid && control?.touched) {
      <div class="field-errors">
        @if (control.hasError('required')) {
          <small>{{ label }} is required</small>
        }
        @if (control.hasError('email')) {
          <small>Enter a valid email address</small>
        }
        @if (control.hasError('minlength')) {
          <small>Minimum {{ control.errors?.['minlength']?.requiredLength }} characters</small>
        }
        @if (control.hasError('maxlength')) {
          <small>Maximum {{ control.errors?.['maxlength']?.requiredLength }} characters</small>
        }
        @if (control.hasError('min')) {
          <small>Minimum value is {{ control.errors?.['min']?.min }}</small>
        }
        @if (control.hasError('pattern')) {
          <small>Invalid format</small>
        }
      </div>
    }
  `,
  styles: [`
    .field-errors small {
      color: #dc3545;
      display: block;
      font-size: 0.85em;
      margin-top: 4px;
    }
  `],
})
export class FieldErrorComponent {
  @Input() control: AbstractControl | null = null;
  @Input() label = 'Field';
}
html
<!-- Usage -->
<input formControlName="email">
<app-field-error [control]="form.get('email')" label="Email" />

Validation Best Practices

  1. Validate on blur — show errors after the user leaves the field, not while typing
  2. Show one error at a time — prioritize the most important validation message
  3. Use async validators sparingly — they trigger API calls on every change
  4. Debounce async validators — add debounceTime(300) to avoid excessive API calls
  5. Create reusable validator functions — keep them in a separate file
  6. Disable submit button when the form is invalid
  7. Provide clear error messages — tell the user exactly what's wrong and how to fix it

Next Steps

With forms and validation complete, let's explore RxJS — the reactive programming library that powers Angular's data flow, event handling, and asynchronous operations.