Skip to content

Angular Form Validation Explained — Advanced Validation Techniques

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Angular Form Validation Explained. We cover key concepts, practical examples, and best practices to help you master this topic.

Angular form validation ensures user input meets requirements before submission, using built-in validators, custom validation functions, and async validators for server-side checks.

What You'll Learn

  • How built-in validators work (required, minLength, email, pattern)
  • How to create custom synchronous validators
  • How to create cross-field validators
  • How to create async validators for server checks
  • How to display validation error messages effectively

Why It Matters

Validation prevents invalid data from reaching your backend, improves user experience with immediate feedback, and reduces server load by catching errors on the client side first.

Real-World Use

Durga Antivirus Pro's user registration form validates email format, password strength (min length, uppercase, digit), password match confirmation, and checks username availability via an async validator that queries the server.

flowchart TD
    A[User Input] --> B{Validators}
    B --> C[Sync Validators]
    B --> D[Async Validators]
    C --> E{Valid?}
    D --> F{Available?}
    E -->|No| G[Error Message]
    E -->|Yes| D
    F -->|No| H[Unavailable Error]
    F -->|Yes| I[Valid Form]
    style A fill:#f97316,color:#fff

Built-in Validators

Angular provides several built-in validators through the Validators class:

import { Component } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-validation-demo",
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  template: `
    <form [formGroup]="userForm">
      <label>Username:
        <input formControlName="username" />
        <span class="error" *ngIf="username.errors?.['required'] && username.touched">Required</span>
        <span class="error" *ngIf="username.errors?.['minlength']">
          Min {{ username.errors?.['minlength'].requiredLength }} chars
        </span>
      </label>
      <label>Email:
        <input formControlName="email" type="email" />
        <span class="error" *ngIf="email.errors?.['email'] && email.touched">Invalid email</span>
      </label>
      <label>Age:
        <input formControlName="age" type="number" />
        <span class="error" *ngIf="age.errors?.['min']">Min 18 years</span>
      </label>
      <label>Website:
        <input formControlName="website" />
        <span class="error" *ngIf="website.errors?.['pattern']">Invalid URL</span>
      </label>
      <button type="submit" [disabled]="userForm.invalid">Submit</button>
    </form>
  `,
  styles: [`.error { color: #ef4444; font-size: 0.85rem; display: block; }`]
})
export class ValidationDemoComponent {
  private fb = new FormBuilder().nonNullable;

  userForm = this.fb.group({
    username: ["", [Validators.required, Validators.minLength(3)]],
    email: ["", [Validators.required, Validators.email]],
    age: [0, [Validators.required, Validators.min(18)]],
    website: ["", [Validators.pattern(/^https?:\/\/.+/)]],
  });

  get username() { return this.userForm.get("username")!; }
  get email() { return this.userForm.get("email")!; }
  get age() { return this.userForm.get("age")!; }
  get website() { return this.userForm.get("website")!; }
}

Expected output: Each field displays a specific error message when validation fails. The submit button is disabled until all validations pass.

Built-in validators are functions that return null if valid or an error object if invalid. The error object key is the validator name and the value contains details like requiredLength and actual.

Custom Validators

Create custom validators for business rules:

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

export function passwordStrengthValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value || "";
    const hasUpperCase = /[A-Z]/.test(value);
    const hasLowerCase = /[a-z]/.test(value);
    const hasDigit = /\d/.test(value);
    const hasMinLength = value.length >= 8;

    const valid = hasUpperCase && hasLowerCase && hasDigit && hasMinLength;
    return valid ? null : { passwordStrength: {
      hasUpperCase: !hasUpperCase,
      hasLowerCase: !hasLowerCase,
      hasDigit: !hasDigit,
      hasMinLength: !hasMinLength,
    }};
  };
}

// Custom validator factory with parameter
export function rangeValidator(min: number, max: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value;
    if (value === null || value === undefined || value === "") return null;
    const valid = !isNaN(value) && value >= min && value <= max;
    return valid ? null : { range: { min, max, actual: value } };
  };
}

Usage in component:

import { Component } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { passwordStrengthValidator, rangeValidator } from "./validators";

@Component({
  selector: "app-register",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="registerForm">
      <input formControlName="password" type="password" placeholder="Password" />
      <span *ngIf="password.errors?.['passwordStrength']">
        Need uppercase, lowercase, digit, min 8 chars
      </span>
      <input formControlName="score" type="number" placeholder="Score (0-100)" />
      <span *ngIf="score.errors?.['range']">
        Score must be {{ score.errors?.['range'].min }}-{{ score.errors?.['range'].max }}
      </span>
    </form>
  `
})
export class RegisterComponent {
  private fb = new FormBuilder().nonNullable;

  registerForm = this.fb.group({
    password: ["", [Validators.required, passwordStrengthValidator()]],
    score: [0, [rangeValidator(0, 100)]],
  });

  get password() { return this.registerForm.get("password")!; }
  get score() { return this.registerForm.get("score")!; }
}

Expected output: Password field shows strength requirements. Score field validates range 0-100.

A custom validator is a function that takes an AbstractControl and returns either null (valid) or a ValidationErrors object (invalid). The error object can contain nested details for precise error messages.

Cross-Field Validation

Validate relationships between multiple fields:

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

export function passwordMatchValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const password = control.get("password");
    const confirm = control.get("confirmPassword");
    if (!password || !confirm) return null;
    const match = password.value === confirm.value;
    return match ? null : { passwordMismatch: true };
  };
}

export function dateRangeValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const start = group.get("startDate")?.value;
    const end = group.get("endDate")?.value;
    if (!start || !end) return null;
    return new Date(start) <= new Date(end) ? null : { dateRange: true };
  };
}

Usage:

this.registrationForm = this.fb.group({
  password: ["", Validators.required],
  confirmPassword: ["", Validators.required],
}, { validators: passwordMatchValidator() });

Expected output: When the two password fields do not match, the entire form group has a passwordMismatch error.

Cross-field validators are applied at the FormGroup level using the validators option in the group configuration. The validator receives the entire group and can compare any controls within it.

Async Validators

Check validity against a server or async operation:

import { Injectable } from "@angular/core";
import { AbstractControl, AsyncValidator, ValidationErrors } from "@angular/forms";
import { Observable, of } from "rxjs";
import { debounceTime, distinctUntilChanged, map, catchError, first } from "rxjs/operators";
import { HttpClient } from "@angular/common/http";

@Injectable({ providedIn: "root" })
export class UsernameValidator implements AsyncValidator {
  constructor(private http: HttpClient) {}

  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    if (!control.value || control.value.length < 3) return of(null);
    return this.http.get<{ available: boolean }>(
      `/api/check-username?username=${control.value}`
    ).pipe(
      map(response => response.available ? null : { usernameTaken: true }),
      catchError(() => of(null)),
      first()
    );
  }
}

Usage:

import { Component, inject } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { UsernameValidator } from "./username-validator";

@Component({
  selector: "app-username-check",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <input formControlName="username" placeholder="Choose username" />
    <span *ngIf="username.pending">Checking availability...</span>
    <span *ngIf="username.errors?.['usernameTaken']">Username already taken</span>
  `
})
export class UsernameCheckComponent {
  private fb = inject(FormBuilder);
  private usernameValidator = inject(UsernameValidator);

  form = this.fb.group({
    username: ["", {
      validators: [Validators.required, Validators.minLength(3)],
      asyncValidators: [this.usernameValidator.validate.bind(this.usernameValidator)],
      updateOn: "blur"
    }]
  });

  get username() { return this.form.get("username")!; }
}

Expected output: When the user blurs the username field, it checks availability against the server and displays "Checking..." while the request is pending.

Async validators use updateOn: "blur" to avoid checking on every keystroke. The control enters the pending state while the async check is in progress. The validator function returns an Observable or Promise.

Error Display Strategies

Create a reusable error display component:

import { Component, Input } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AbstractControl } from "@angular/forms";

@Component({
  selector: "app-control-error",
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="error-container" *ngIf="control && control.invalid && (control.dirty || control.touched)">
      <span *ngIf="control.errors?.['required']">This field is required</span>
      <span *ngIf="control.errors?.['email']">Please enter a valid email</span>
      <span *ngIf="control.errors?.['minlength']">
        Min {{ control.errors?.['minlength'].requiredLength }} characters ({{ control.errors?.['minlength'].actualLength }})
      </span>
      <span *ngIf="control.errors?.['passwordStrength']">Password is too weak</span>
      <span *ngIf="control.errors?.['usernameTaken']">Username is already taken</span>
      <span *ngIf="control.errors?.['range']">
        Value must be between {{ control.errors?.['range'].min }} and {{ control.errors?.['range'].max }}
      </span>
    </div>
  `,
  styles: [`.error-container { color: #ef4444; font-size: 0.85rem; margin-top: 4px; }`]
})
export class ControlErrorComponent {
  @Input() control: AbstractControl | null = null;
}

Usage:

<input formControlName="email" type="email" />
<app-control-error [control]="email"></app-control-error>

Expected output: The error component displays relevant messages based on the control's validation errors, showing only when the control is dirty or touched.

Centralizing error display logic in a reusable component keeps templates clean and ensures consistent error styling across the application.

Common Mistakes

  1. Not checking dirty or touched before showing errors — Displaying errors on pristine controls shows errors before the user has interacted, creating a bad experience.

  2. Async validators that fire on every keystroke — Always use updateOn: "blur" or debounceTime for async validators to avoid excessive API calls.

  3. Mutating validation state in validators — Validators must be pure functions. Calling control.setErrors() inside a validator creates infinite loops.

  4. Forgetting to return null for valid state — A validator must return null when valid. Returning undefined or nothing does not clear the error state.

  5. Complex logic in template error checks — Move error message logic to the component or a reusable error component to keep templates readable.

Practice Questions

  1. What does a validator return when valid? null. When invalid, it returns a ValidationErrors object (a dictionary with string keys).

  2. How do you create a custom validator? Create a function that implements ValidatorFn: takes AbstractControl, returns ValidationErrors | null.

  3. What is the difference between sync and async validators? Sync validators return the result immediately. Async validators return an Observable or Promise that resolves with the result.

  4. How do you apply validators to a FormGroup? Pass them in the options object: new FormGroup({...}, { validators: [crossFieldValidator] }).

  5. What does the updateOn option do? Controls when validation runs: change (default, on every value change), blur (on blur), or submit (on form submit).

Challenge

Build a PasswordChangeFormComponent with current password, new password, and confirm password fields. Add validators: new password must match strength rules (8+ chars, uppercase, digit), must differ from current password, and confirm must match new. Use cross-field validation at the group level for the match check and a custom validator for the "differ from current" check.

FAQ

Can validators be async only?

Yes, if you have no sync validators, pass only asyncValidators in the control options.

How do I trigger validation manually?

Call control.updateValueAndValidity() to re-run all validators on a control or form.

What happens during the pending state?

The control's pending property is true. The form is invalid while any control is pending.

Can I compose multiple validators?

Yes, use an array: [Validators.required, myCustomValidator, myOtherValidator].

How do I clear errors?

Call control.setErrors(null) to clear all errors programmatically.

Mini Project

Build a TeamInviteFormComponent with email input (valid email, not a disposable domain), role select (admin/editor/viewer), and an invite button. Add an async validator that checks if the email is already a team member by calling a mock API. Show a confirm dialog before sending the invite. Display a success message with the invited email and role after submission.

What's Next

Continue with HTTP interceptors and route guards:

Angular HTTP Interceptors, Angular Guards, Angular Resolvers

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro