Form validation combines field rules, interaction state, cross-field checks, asynchronous work, and server feedback. This lesson shows where validators belong and when an error should become visible to the user.
Angular provides a powerful validation system for both Reactive and Template-driven forms. Validators can be built-in (required, minLength, email) or custom. Angular tracks the validity state of each control and the form as a whole, making it easy to show error messages and disable submit buttons.
Client validation provides immediate feedback and reduces avoidable requests, but it is not a trust boundary. Repeat every business and security rule on the server because a caller can bypass the Angular form entirely.
Show errors after a field is touched or after submission, associate messages with controls, and use role="alert" or an appropriate live region for feedback that appears dynamically. Do not rely on color alone.
Disabled controls are excluded from FormGroup.value. Use getRawValue only when the submitted contract intentionally includes them, and never confuse disabled UI with server authorization.
| Built-in Validator | Description | Usage |
|---|---|---|
| Validators.required | Field must not be empty | required attribute or Validators.required |
| Validators.minLength(n) | Minimum character count | minlength="3" or Validators.minLength(3) |
| Validators.maxLength(n) | Maximum character count | maxlength="50" or Validators.maxLength(50) |
| Validators.email | Valid email format | email attribute or Validators.email |
| Validators.pattern(regex) | Must match regex pattern | Validators.pattern(/^[0-9]+$/) |
| Validators.min(n) | Minimum numeric value | Validators.min(0) |
| Validators.max(n) | Maximum numeric value | Validators.max(100) |
In reactive forms, validators are added programmatically in the component class. This gives full control over validation logic and makes it easy to test.
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-register',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './register.component.html'
})
export class RegisterComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
age: [null, [Validators.required, Validators.min(18), Validators.max(120)]]
});
// Convenience getters for template access
get name() { return this.form.get('name')!; }
get email() { return this.form.get('email')!; }
get password() { return this.form.get('password')!; }
get age() { return this.form.get('age')!; }
onSubmit() {
if (this.form.valid) {
console.log('Form submitted:', this.form.value);
} else {
this.form.markAllAsTouched();
}
}
}
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label>Name</label>
<input formControlName="name" />
@if (name.invalid && name.touched) {
@if (name.errors?.['required']) {
<span class="tl-text-danger">Name is required.</span>
}
@if (name.errors?.['minlength']) {
<span class="tl-text-danger">Name must be at least 2 characters.</span>
}
}
</div>
<div>
<label>Email</label>
<input formControlName="email" type="email" />
@if (email.invalid && email.touched) {
@if (email.errors?.['required']) {
<span class="tl-text-danger">Email is required.</span>
}
@if (email.errors?.['email']) {
<span class="tl-text-danger">Enter a valid email address.</span>
}
}
</div>
<button type="submit" [disabled]="form.invalid">Register</button>
</form>
When built-in validators are not enough, you can create custom validators. A validator is a function that takes an AbstractControl and returns either null (valid) or a ValidationErrors object (invalid).
Return a stable error key with useful details, such as { dateRange: { start, end } }, so the template can choose an accurate message. Keep synchronous validators pure and free of HTTP calls.
Use an AsyncValidatorFn for a server-backed check and debounce upstream input when appropriate. Angular marks the control pending until the observable or promise completes; ensure the validator completes and handle transport failure separately from a genuine invalid result.
Cross-field rules belong on the nearest FormGroup that owns all participating controls. Display the group error near the related controls and clear it by returning null when the relationship becomes valid.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// 1. No whitespace validator
export function noWhitespaceValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const hasWhitespace = (control.value || '').includes(' ');
return hasWhitespace ? { whitespace: true } : null;
};
}
// 2. Password strength validator
export function passwordStrengthValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value || '';
const hasUpper = /[A-Z]/.test(value);
const hasLower = /[a-z]/.test(value);
const hasNumber = /[0-9]/.test(value);
const hasSpecial = /[!@#$%^&*]/.test(value);
const isStrong = hasUpper && hasLower && hasNumber && hasSpecial;
return isStrong ? null : { weakPassword: { hasUpper, hasLower, hasNumber, hasSpecial } };
};
}
// 3. Cross-field validator - passwords must match
export function passwordMatchValidator(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 };
}
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { noWhitespaceValidator, passwordStrengthValidator, passwordMatchValidator } from './custom-validators';
@Component({
selector: 'app-signup',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="username" placeholder="Username" />
@if (form.get('username')?.errors?.['whitespace']) {
<span class="tl-text-danger">No spaces allowed.</span>
}
<input formControlName="password" type="password" placeholder="Password" />
@if (form.get('password')?.errors?.['weakPassword']) {
<span class="tl-text-danger">Password needs uppercase, lowercase, number and special char.</span>
}
<input formControlName="confirmPassword" type="password" placeholder="Confirm Password" />
@if (form.errors?.['passwordMismatch']) {
<span class="tl-text-danger">Passwords do not match.</span>
}
<button type="submit" [disabled]="form.invalid">Sign Up</button>
</form>
`
})
export class SignupComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
username: ['', [Validators.required, noWhitespaceValidator()]],
password: ['', [Validators.required, passwordStrengthValidator()]],
confirmPassword: ['', Validators.required]
}, { validators: passwordMatchValidator });
onSubmit() {
if (this.form.valid) console.log(this.form.value);
}
}
Control state answers two separate questions: whether the value satisfies the rules and whether the user has interacted enough to need feedback. Keep an attempted-submit flag at the form boundary so invalid untouched fields become visible after submission.
Use updateOn: "blur" when validation should wait until a field loses focus and updateOn: "submit" for forms that should validate as a unit. This changes when value and validation updates occur, so test keyboard, paste, and submit behavior rather than using it only to hide messages.
| State | Property | Meaning |
|---|---|---|
| Pristine | control.pristine | User has not changed the value yet |
| Dirty | control.dirty | User has changed the value |
| Untouched | control.untouched | User has not focused and left the field |
| Touched | control.touched | User has focused and left the field |
| Valid | control.valid | All validators pass |
| Invalid | control.invalid | At least one validator fails |
| Pending | control.pending | Async validator is running |
export function usernameAvailable(api: AccountsApi): AsyncValidatorFn {
return control => {
const value = String(control.value ?? '').trim();
if (!value) return of(null);
return timer(250).pipe(
switchMap(() => api.usernameExists(value)),
map(exists => exists ? { usernameTaken: { value } } : null),
catchError(() => of({ availabilityUnavailable: true })),
take(1)
);
};
}
readonly username = new FormControl('', {
nonNullable: true,
validators: [Validators.required],
asyncValidators: [usernameAvailable(inject(AccountsApi))],
updateOn: 'blur'
});
applyServerErrors(errors: Record<string, string>): void {
for (const [field, message] of Object.entries(errors)) {
const control = this.form.get(field);
if (!control) continue;
control.setErrors({ ...control.errors, server: message });
}
}
clearServerError(control: AbstractControl): void {
const { server, ...remaining } = control.errors ?? {};
control.setErrors(Object.keys(remaining).length ? remaining : null);
}
Signal Forms define validation in a schema over a signal-backed model. Use built-in schema rules for field constraints, validate a group path for cross-field rules, and read the field state for errors, touched, dirty, pending, and validity.
Keep one form model per screen and map it to the server command at submission. Server authorization and business validation remain authoritative even when the client schema matches the same rules.
interface Credentials {
email: string;
password: string;
}
const model = signal<Credentials>({
email: '',
password: ''
});
const credentialsSchema = schema<Credentials>(path => {
required(path.email, { message: 'Email is required' });
email(path.email, { message: 'Enter a valid email' });
minLength(path.password, 12, {
message: 'Use at least 12 characters'
});
});
const credentialsForm = form(model, credentialsSchema);
A validator must return null for valid input and a ValidationErrors object only for invalid input. Common bugs include returning an empty object, testing stale component state instead of control.value, or assuming every value is a string.
Usually after the field is touched, or after an attempted submit. A required control is invalid from the moment it is created; that does not mean the user needs an error before interacting.
The validator may be attached to the password control even though it depends on two controls. Put cross-field validation on the containing FormGroup so either child change triggers reevaluation, then expose a group-level mismatch error.
Explore 500+ free tutorials across 20+ languages and frameworks.