Angular Form Validation Explained — Advanced Validation Techniques
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
Not checking dirty or touched before showing errors — Displaying errors on pristine controls shows errors before the user has interacted, creating a bad experience.
Async validators that fire on every keystroke — Always use
updateOn: "blur"ordebounceTimefor async validators to avoid excessive API calls.Mutating validation state in validators — Validators must be pure functions. Calling
control.setErrors()inside a validator creates infinite loops.Forgetting to return null for valid state — A validator must return
nullwhen valid. Returningundefinedor nothing does not clear the error state.Complex logic in template error checks — Move error message logic to the component or a reusable error component to keep templates readable.
Practice Questions
What does a validator return when valid?
null. When invalid, it returns aValidationErrorsobject (a dictionary with string keys).How do you create a custom validator? Create a function that implements
ValidatorFn: takesAbstractControl, returnsValidationErrors | null.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.
How do you apply validators to a FormGroup? Pass them in the options object:
new FormGroup({...}, { validators: [crossFieldValidator] }).What does the
updateOnoption do? Controls when validation runs:change(default, on every value change),blur(on blur), orsubmit(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
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