Skip to main content
Form validation is essential for ensuring data quality and providing a good user experience. Angular provides built-in validators and allows you to create custom validators for both reactive and template-driven forms.

Built-in Validators

Angular includes several built-in validators in the Validators class:

required

Requires a non-empty value

email

Validates email format

min

Minimum numeric value

max

Maximum numeric value

minLength

Minimum string/array length

maxLength

Maximum string/array length

pattern

Validates against regex

requiredTrue

Requires true value (checkboxes)

nullValidator

No-op validator

Using Validators in Reactive Forms

Single Validator

packages/forms/src/validators.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `...`
})
export class SignupComponent {
  // Single validator
  email = new FormControl('', Validators.required);
  
  // Check validation state
  ngOnInit() {
    console.log(this.email.errors); // { required: true }
    console.log(this.email.valid); // false
  }
}

Multiple Validators

packages/forms/src/validators.ts
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-profile',
  template: `...`
})
export class ProfileComponent {
  constructor(private fb: FormBuilder) {}
  
  profileForm = this.fb.group({
    // Multiple validators as array
    email: ['', [Validators.required, Validators.email]],
    
    // Numeric validators
    age: [null, [Validators.required, Validators.min(18), Validators.max(100)]],
    
    // String length validators
    username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(20)]],
    
    // Pattern validator
    phone: ['', [Validators.required, Validators.pattern(/^\d{3}-\d{3}-\d{4}$/)]],
    
    // Required true (for checkboxes)
    terms: [false, Validators.requiredTrue]
  });
}

Displaying Validation Errors

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

@Component({
  selector: 'app-registration',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  template: `
    <form [formGroup]="registrationForm">
      <div>
        <label for="email">Email:</label>
        <input id="email" type="email" formControlName="email">
        
        <div *ngIf="email.invalid && (email.dirty || email.touched)">
          <p *ngIf="email.errors?.['required']" style="color: red;">
            Email is required
          </p>
          <p *ngIf="email.errors?.['email']" style="color: red;">
            Invalid email format
          </p>
        </div>
      </div>
      
      <div>
        <label for="password">Password:</label>
        <input id="password" type="password" formControlName="password">
        
        <div *ngIf="password.invalid && (password.dirty || password.touched)">
          <p *ngIf="password.errors?.['required']" style="color: red;">
            Password is required
          </p>
          <p *ngIf="password.errors?.['minlength']" style="color: red;">
            Password must be at least 
            {{ password.errors?.['minlength'].requiredLength }} characters
            (current: {{ password.errors?.['minlength'].actualLength }})
          </p>
        </div>
      </div>
      
      <button type="submit" [disabled]="registrationForm.invalid">Register</button>
    </form>
  `
})
export class RegistrationComponent {
  constructor(private fb: FormBuilder) {}
  
  registrationForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });
  
  get email() {
    return this.registrationForm.get('email')!;
  }
  
  get password() {
    return this.registrationForm.get('password')!;
  }
}

Using Validators in Template-Driven Forms

Built-in Validation Directives

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-contact',
  standalone: true,
  imports: [FormsModule, CommonModule],
  template: `
    <form #contactForm="ngForm">
      <div>
        <label for="name">Name:</label>
        <input 
          id="name" 
          name="name" 
          type="text" 
          [(ngModel)]="contact.name"
          #nameField="ngModel"
          required
          minlength="2">
        
        <div *ngIf="nameField.invalid && nameField.touched">
          <p *ngIf="nameField.errors?.['required']" style="color: red;">
            Name is required
          </p>
          <p *ngIf="nameField.errors?.['minlength']" style="color: red;">
            Name must be at least 2 characters
          </p>
        </div>
      </div>
      
      <div>
        <label for="email">Email:</label>
        <input 
          id="email" 
          name="email" 
          type="email" 
          [(ngModel)]="contact.email"
          #emailField="ngModel"
          required
          email>
        
        <div *ngIf="emailField.invalid && emailField.touched">
          <p *ngIf="emailField.errors?.['required']" style="color: red;">
            Email is required
          </p>
          <p *ngIf="emailField.errors?.['email']" style="color: red;">
            Invalid email format
          </p>
        </div>
      </div>
      
      <div>
        <label for="age">Age:</label>
        <input 
          id="age" 
          name="age" 
          type="number" 
          [(ngModel)]="contact.age"
          #ageField="ngModel"
          min="18"
          max="100">
        
        <div *ngIf="ageField.invalid && ageField.touched">
          <p *ngIf="ageField.errors?.['min']" style="color: red;">
            Must be at least 18
          </p>
          <p *ngIf="ageField.errors?.['max']" style="color: red;">
            Must be 100 or less
          </p>
        </div>
      </div>
      
      <div>
        <label for="phone">Phone:</label>
        <input 
          id="phone" 
          name="phone" 
          type="tel" 
          [(ngModel)]="contact.phone"
          #phoneField="ngModel"
          pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}">
        
        <div *ngIf="phoneField.invalid && phoneField.touched">
          <p *ngIf="phoneField.errors?.['pattern']" style="color: red;">
            Format: 123-456-7890
          </p>
        </div>
      </div>
    </form>
  `
})
export class ContactComponent {
  contact = {
    name: '',
    email: '',
    age: null,
    phone: ''
  };
}

Custom Validators

Creating a Custom Validator Function

packages/forms/src/validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Validator that checks if password contains a number
export function passwordStrengthValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value;
    
    if (!value) {
      return null;
    }
    
    const hasNumber = /[0-9]/.test(value);
    const hasUpper = /[A-Z]/.test(value);
    const hasLower = /[a-z]/.test(value);
    const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value);
    
    const passwordValid = hasNumber && hasUpper && hasLower && hasSpecial;
    
    return !passwordValid ? { passwordStrength: {
      hasNumber,
      hasUpper,
      hasLower,
      hasSpecial
    }} : null;
  };
}

// Usage in reactive forms
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-password-form',
  template: `
    <form [formGroup]="form">
      <input type="password" formControlName="password">
      
      <div *ngIf="password.invalid && password.touched">
        <p *ngIf="password.errors?.['passwordStrength']">
          Password must contain:
          <span *ngIf="!password.errors?.['passwordStrength'].hasNumber">number, </span>
          <span *ngIf="!password.errors?.['passwordStrength'].hasUpper">uppercase, </span>
          <span *ngIf="!password.errors?.['passwordStrength'].hasLower">lowercase, </span>
          <span *ngIf="!password.errors?.['passwordStrength'].hasSpecial">special char</span>
        </p>
      </div>
    </form>
  `
})
export class PasswordFormComponent {
  constructor(private fb: FormBuilder) {}
  
  form = this.fb.group({
    password: ['', [Validators.required, Validators.minLength(8), passwordStrengthValidator()]]
  });
  
  get password() {
    return this.form.get('password')!;
  }
}

Cross-Field Validation

Validate multiple fields together:
packages/forms/src/validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

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

// Usage
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  template: `
    <form [formGroup]="signupForm">
      <div>
        <label>Password:</label>
        <input type="password" formControlName="password">
      </div>
      
      <div>
        <label>Confirm Password:</label>
        <input type="password" formControlName="confirmPassword">
        
        <div *ngIf="signupForm.errors?.['passwordMismatch'] && 
                    signupForm.get('confirmPassword')?.touched">
          <p style="color: red;">Passwords do not match</p>
        </div>
      </div>
      
      <button type="submit" [disabled]="signupForm.invalid">Sign Up</button>
    </form>
  `
})
export class SignupFormComponent {
  constructor(private fb: FormBuilder) {}
  
  signupForm = this.fb.group({
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required]
  }, { validators: passwordMatchValidator() });
}

Custom Validator Directive

For template-driven forms:
packages/forms/src/directives/validators.ts
import { Directive } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';

@Directive({
  selector: '[appForbiddenName]',
  standalone: true,
  providers: [
    { provide: NG_VALIDATORS, useExisting: ForbiddenNameDirective, multi: true }
  ]
})
export class ForbiddenNameDirective implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    const forbidden = /admin|root|superuser/i.test(control.value);
    return forbidden ? { forbiddenName: { value: control.value } } : null;
  }
}

// Usage in template
/*
<input 
  name="username" 
  [(ngModel)]="username"
  #usernameField="ngModel"
  appForbiddenName
  required>

<div *ngIf="usernameField.errors?.['forbiddenName']">
  <p>This username is not allowed</p>
</div>
*/

Async Validators

Async validators return a Promise or Observable for server-side validation:
packages/forms/src/validators.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap } from 'rxjs/operators';

export function uniqueUsernameValidator(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) {
      return of(null);
    }
    
    return control.valueChanges.pipe(
      debounceTime(300),
      switchMap(value => userService.checkUsername(value)),
      map(isTaken => (isTaken ? { usernameTaken: true } : null)),
      catchError(() => of(null))
    );
  };
}

// Usage
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-username-check',
  template: `
    <form [formGroup]="form">
      <input type="text" formControlName="username">
      
      <div *ngIf="username.pending">
        <p>Checking username availability...</p>
      </div>
      
      <div *ngIf="username.errors?.['usernameTaken'] && username.touched">
        <p style="color: red;">Username is already taken</p>
      </div>
      
      <div *ngIf="username.valid">
        <p style="color: green;">Username is available!</p>
      </div>
    </form>
  `
})
export class UsernameCheckComponent {
  constructor(
    private fb: FormBuilder,
    private userService: UserService
  ) {}
  
  form = this.fb.group({
    username: [
      '',
      [Validators.required, Validators.minLength(3)],
      [uniqueUsernameValidator(this.userService)]
    ]
  });
  
  get username() {
    return this.form.get('username')!;
  }
}

Validation Status

Form controls have several status properties:
const control = new FormControl('', Validators.required);

// Validation status
console.log(control.valid);      // false
console.log(control.invalid);    // true
console.log(control.pending);    // false (true during async validation)
console.log(control.errors);     // { required: true }

// User interaction status
console.log(control.pristine);   // true (not changed)
console.log(control.dirty);      // false (changed)
console.log(control.untouched);  // true (not visited)
console.log(control.touched);    // false (visited)

// Overall status string
console.log(control.status);     // 'INVALID' | 'VALID' | 'PENDING' | 'DISABLED'

Dynamic Validation

Add or remove validators dynamically:
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-dynamic-validation',
  template: `
    <form [formGroup]="form">
      <label>
        <input type="checkbox" (change)="toggleRequired($event)">
        Make email required
      </label>
      
      <input type="email" formControlName="email">
      <p *ngIf="email.invalid && email.touched" style="color: red;">
        Email is required
      </p>
    </form>
  `
})
export class DynamicValidationComponent {
  constructor(private fb: FormBuilder) {}
  
  form = this.fb.group({
    email: ['']
  });
  
  get email() {
    return this.form.get('email')!;
  }
  
  toggleRequired(event: any) {
    const emailControl = this.email;
    
    if (event.target.checked) {
      // Add validators
      emailControl.setValidators([Validators.required, Validators.email]);
    } else {
      // Remove validators
      emailControl.clearValidators();
    }
    
    // Must call updateValueAndValidity after changing validators
    emailControl.updateValueAndValidity();
  }
}

Validation Error Messages

Create reusable error message components:
import { Component, Input } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-validation-errors',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div *ngIf="control.invalid && (control.dirty || control.touched)" 
         style="color: red; margin-top: 5px;">
      <p *ngIf="control.errors?.['required']">This field is required</p>
      <p *ngIf="control.errors?.['email']">Invalid email format</p>
      <p *ngIf="control.errors?.['minlength']">
        Minimum length is {{ control.errors?.['minlength'].requiredLength }}
      </p>
      <p *ngIf="control.errors?.['maxlength']">
        Maximum length is {{ control.errors?.['maxlength'].requiredLength }}
      </p>
      <p *ngIf="control.errors?.['min']">
        Minimum value is {{ control.errors?.['min'].min }}
      </p>
      <p *ngIf="control.errors?.['max']">
        Maximum value is {{ control.errors?.['max'].max }}
      </p>
      <p *ngIf="control.errors?.['pattern']">
        Invalid format
      </p>
    </div>
  `
})
export class ValidationErrorsComponent {
  @Input() control!: AbstractControl;
}

// Usage
/*
<input formControlName="email">
<app-validation-errors [control]="form.get('email')!"></app-validation-errors>
*/

Best Practices

Check touched or dirty before showing errors to avoid overwhelming users.
control.invalid && (control.dirty || control.touched)
Tell users exactly what they need to fix, including requirements.
Debounce async validators to reduce server calls.
control.valueChanges.pipe(debounceTime(300))
Use Validators.compose() to combine multiple validators.
Validators.compose([Validators.required, Validators.email])
Remember to call updateValueAndValidity() after changing validators.

Testing Form Validation

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { LoginComponent } from './login.component';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ReactiveFormsModule, LoginComponent]
    });
    
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
  });
  
  it('should create form with required validators', () => {
    expect(component.loginForm.get('email')?.hasError('required')).toBe(true);
    expect(component.loginForm.valid).toBe(false);
  });
  
  it('should validate email format', () => {
    const emailControl = component.loginForm.get('email');
    emailControl?.setValue('invalid-email');
    expect(emailControl?.hasError('email')).toBe(true);
    
    emailControl?.setValue('[email protected]');
    expect(emailControl?.hasError('email')).toBe(false);
  });
  
  it('should mark form as valid when all fields are valid', () => {
    component.loginForm.patchValue({
      email: '[email protected]',
      password: 'password123'
    });
    expect(component.loginForm.valid).toBe(true);
  });
});

Next Steps

Reactive Forms

Learn more about reactive forms

Template-Driven Forms

Learn more about template-driven forms

Build docs developers (and LLMs) love