Angular Form Validation
Form validation ensures users provide correct, complete data before it is submitted. Angular's validation pipeline runs synchronously or asynchronously, integrates with both template-driven and reactive forms, and automatically tracks per-control error state.
This page covers built-in validators, error display patterns, cross-field validation, and async validation.
Built-in Validators
Validator | Template attribute | Reactive import | What it checks |
|---|---|---|---|
required | required | Validators.required | Non-empty value |
Validators.email | Valid email format | ||
minlength | minlength="N" | Validators.minLength(N) | String length >= N |
maxlength | maxlength="N" | Validators.maxLength(N) | String length <= N |
min | min="N" | Validators.min(N) | Number >= N |
max | max="N" | Validators.max(N) | Number <= N |
pattern | pattern="regex" | Validators.pattern(regex) | Matches regular expression |
nullValidator | — | Validators.nullValidator | Always valid (no-op) |
Applying Validators in Reactive Forms
import { FormGroup, FormControl, Validators } from '@angular/forms';
const form = new FormGroup({
username: new FormControl('', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(20),
Validators.pattern(/^[a-zA-Z0-9_]+$/),
]),
age: new FormControl<number | null>(null, [
Validators.required,
Validators.min(18),
Validators.max(120),
]),
website: new FormControl('', [
Validators.pattern(/^https?:\/\/.+/),
]),
});Displaying Validation Errors
Show errors only after the user has interacted with the field (touched) or tried to submit the form — not while they are still typing for the first time.
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label for="username">Username</label>
<input id="username" formControlName="username" />
<!-- Show individual error messages -->
<div *ngIf="form.get('username') as ctrl">
<ng-container *ngIf="ctrl.invalid && (ctrl.dirty || ctrl.touched)">
<p *ngIf="ctrl.hasError('required')">Username is required.</p>
<p *ngIf="ctrl.hasError('minlength')">
Minimum {{ ctrl.getError('minlength').requiredLength }} characters.
</p>
<p *ngIf="ctrl.hasError('maxlength')">
Maximum {{ ctrl.getError('maxlength').requiredLength }} characters.
</p>
<p *ngIf="ctrl.hasError('pattern')">
Only letters, numbers, and underscores allowed.
</p>
</ng-container>
</div>
</div>
<button type="submit" [disabled]="form.invalid">Register</button>
</form>ctrl.getError('minlength') returns the error details object: { requiredLength: 3, actualLength: 1 }. Use it to show helpful, specific messages rather than generic ones.Marking All Controls Touched on Submit
Users sometimes skip fields and click Submit. Call markAllAsTouched() on submit to trigger error display for every unfilled field.
onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched(); // reveal errors on all controls
return;
}
// process form.value
}Cross-Field (Group-Level) Validation
Some validations span multiple fields — confirming a password, ensuring start date is before end date. Apply these validators to the FormGroup rather than an individual control.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Cross-field validator function
export const passwordMatchValidator: ValidatorFn = (
group: AbstractControl
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
if (password && confirm && password !== confirm) {
return { passwordMismatch: true };
}
return null;
};
// Apply to FormGroup
const form = new FormGroup(
{
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
confirmPassword: new FormControl('', Validators.required),
},
{ validators: passwordMatchValidator } // group-level validator
);<!-- Display group-level error -->
<p *ngIf="form.hasError('passwordMismatch') && form.get('confirmPassword')?.touched">
Passwords do not match.
</p>Async Validation
Async validators check the server — for example, whether a username is already taken. They return an Observable or Promise.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UniqueEmailValidator {
constructor(private http: HttpClient) {}
validate(): AsyncValidatorFn {
return (ctrl: AbstractControl): Observable<ValidationErrors | null> => {
if (!ctrl.value) return of(null);
return of(ctrl.value).pipe(
debounceTime(400), // wait for user to stop typing
switchMap(email =>
this.http.get<{ exists: boolean }>(`/api/email-check?q=${email}`)
),
map(res => (res.exists ? { emailTaken: true } : null)),
catchError(() => of(null)), // don't block submission on network error
first(), // complete the observable
);
};
}
}// Attach async validator to a FormControl
import { inject } from '@angular/core';
export class SignupComponent {
private emailValidator = inject(UniqueEmailValidator);
form = new FormGroup({
email: new FormControl('', {
validators: [Validators.required, Validators.email],
asyncValidators: [this.emailValidator.validate()],
updateOn: 'blur', // only validate when focus leaves — reduces requests
}),
});
}<input formControlName="email" />
<!-- Pending state while async validator runs -->
<span *ngIf="form.get('email')?.pending">Checking availability...</span>
<!-- Async error -->
<span *ngIf="form.get('email')?.hasError('emailTaken')">
This email is already registered.
</span>updateOn Strategy
By default, Angular validates on every keystroke. You can change this per-control or per-form.
updateOn | When validation runs |
|---|---|
change (default) | On every input event (every keystroke) |
blur | When the field loses focus |
submit | Only when the form is submitted |
// Per-control
new FormControl('', {
validators: Validators.required,
updateOn: 'blur',
})
// Per-form — all controls inherit this default
new FormGroup({ ... }, { updateOn: 'submit' })Template-Driven Validation
Template-driven forms use HTML attributes for validation. Use a template reference to access the control's state.
<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<input
name="email"
type="email"
ngModel
#emailField="ngModel"
required
email
/>
<div *ngIf="emailField.invalid && emailField.touched">
<p *ngIf="emailField.errors?.['required']">Email is required.</p>
<p *ngIf="emailField.errors?.['email']">Enter a valid email address.</p>
</div>
<button [disabled]="f.invalid">Submit</button>
</form>Reactive Error Helper Pattern
A common pattern is to create a getter for each field to reduce verbose template expressions.
export class SignupComponent {
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
// Concise getters used in template
get email() { return this.form.get('email')!; }
get password() { return this.form.get('password')!; }
}<!-- Clean template using getters -->
<input formControlName="email" [class.error]="email.invalid && email.touched" />
<p *ngIf="email.hasError('required') && email.touched">Email required.</p>
<p *ngIf="email.hasError('email') && email.touched">Invalid email format.</p>
<input type="password" formControlName="password" />
<p *ngIf="password.hasError('minlength') && password.touched">
At least {{ password.getError('minlength').requiredLength }} characters.
</p>FieldErrorComponent that accepts a FormControl input and renders the right error message automatically — this eliminates duplicated error markup across your forms.Combining Multiple Validators
import { Validators } from '@angular/forms';
// Compose validators with an array
const emailControl = new FormControl('', [
Validators.required,
Validators.email,
Validators.maxLength(254),
]);
// Or use Validators.compose() (same result, explicit)
const emailControl2 = new FormControl(
'',
Validators.compose([
Validators.required,
Validators.email,
Validators.maxLength(254),
])
);Summary: Angular's validation pipeline supports synchronous validators on individual controls, asynchronous validators for server-side checks, and group-level validators for cross-field rules. Always show errors only after the user has touched a field or tried to submit, and call markAllAsTouched() on a failed submit to reveal all unfilled fields at once.