Form Validation in Angular
By Syncfusion®, Inc. · Last updated
Validating input with built-in and custom validators, control states, clear error messages, cross-field, real-time and conditional validation, and best practices.
Introduction to Form Validation
Form validation checks that the values a user enters follow your rules before the app uses them. Examples of rules are "this field is required", "at least 8 characters" and "must be a valid email address".
Angular runs the rules every time a value changes. It marks each control and the whole form as valid or invalid, and stores which rules failed, so the template can show precise error messages.
Real-World Example
On an account registration page, the email must have a valid format, the password needs at least 8 characters, and the confirm-password field must match the password. A GST number is required only when the user selects "Business account".
The Create account button stays disabled until every rule passes, and each field shows its own message, such as "Password must be at least 8 characters".
Client-side validation improves the user experience, but it does not protect your data. A user can bypass the browser, so the server must validate every value again.
'abc'valueValidatorsrequired, minLength(8){ minlength }errors objectMessageshown when touchedValid values give errors = null. The form is valid only when every control is valid.
Validators turn a value into errors. The template turns errors into messages.
Validation Approaches
Angular supports validation in both form styles. The rules are the same. Only the place where you declare them changes.
| Point | Template-driven | Reactive |
|---|---|---|
| Rules declared in | The template, as attributes | The class, as validator functions |
| Example | <input name="email" ngModel required email> | email: ['', [Validators.required, Validators.email]] |
| Read errors with | A reference: #email="ngModel" | The control: form.controls.email |
| Custom rules | Need a directive | A plain function |
Template-Driven Validation
In a template-driven form, you add standard HTML validation attributes to the field. Angular reads them and turns them into validators: required, minlength, maxlength, pattern and email.
Template-driven validation uses HTML attributes
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [FormsModule],
template: `
<form #f="ngForm">
<input name="username" [(ngModel)]="username" #usernameCtrl="ngModel" required minlength="4" />
@if (usernameCtrl.errors?.['required']) { <p class="error">Username is required.</p> }
@if (usernameCtrl.errors?.['minlength']) { <p class="error">Username needs at least 4 characters.</p> }
</form>
<p>Form valid: {{ f.valid }}</p>
`,
styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
username = 'ab';
}
Reactive Form Validation
In a reactive form, you pass validator functions from the Validators class when you create each control. The second item of a FormBuilder control array holds them.
Reactive validation uses Validators in the class
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input formControlName="username" />
@if (form.controls.username.hasError('required')) { <p class="error">Username is required.</p> }
@if (form.controls.username.hasError('minlength')) { <p class="error">Username needs at least 4 characters.</p> }
</form>
<p>Form valid: {{ form.valid }}</p>
`,
styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
private fb = inject(FormBuilder);
form = this.fb.group({
username: ['ab', [Validators.required, Validators.minLength(4)]],
});
}
Both blocks apply the same two rules. Type more characters and the message disappears.
Built-in Validators
Angular provides validators for the most common rules. Each one adds a named error to the control when its rule fails.
| Rule | Template attribute | Reactive validator | Error key |
|---|---|---|---|
| Required | required | Validators.required | required |
| Minimum length | minlength="8" | Validators.minLength(8) | minlength |
| Maximum length | maxlength="20" | Validators.maxLength(20) | maxlength |
| Pattern | pattern="[0-9]{6}" | Validators.pattern(/^[0-9]{6}$/) | pattern |
email | Validators.email | email | |
| Minimum / maximum number | min="1" / max="10" | Validators.min(1) / Validators.max(10) | min / max |
Error keys are always lower case, even when the validator name is not. Validators.minLength(8) adds the error minlength, so check hasError('minlength').
Required Validation
required fails when the field is empty. For a checkbox, use Validators.requiredTrue to require it to be ticked, for example "I accept the terms".
MinLength Validation
minLength(n) fails when the text has fewer than n characters. The error object contains requiredLength and actualLength, which you can show in the message.
MaxLength Validation
maxLength(n) fails when the text has more than n characters. The template attribute maxlength also stops the browser from accepting more characters.
Pattern Validation
pattern(regex) fails when the value does not match a regular expression. Use it for formats such as PIN codes, phone numbers or product codes.
Email Validation
email fails when the value does not look like an email address. It checks the format only. It cannot check that the mailbox exists.
Built-in validators and their error details
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule, JsonPipe],
template: `
<form [formGroup]="form">
<p>Name <input formControlName="name" /> {{ form.controls.name.errors | json }}</p>
<p>Password <input formControlName="password" /> {{ form.controls.password.errors | json }}</p>
<p>Bio <input formControlName="bio" /> {{ form.controls.bio.errors | json }}</p>
<p>PIN code <input formControlName="pin" /> {{ form.controls.pin.errors | json }}</p>
<p>Email <input formControlName="email" /> {{ form.controls.email.errors | json }}</p>
</form>
`,
})
export class App {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', Validators.required],
password: ['abc', Validators.minLength(8)],
bio: ['A very long biography', Validators.maxLength(10)],
pin: ['56001', Validators.pattern(/^[0-9]{6}$/)],
email: ['asha@', Validators.email],
});
}
Each line shows the control's errors object. Fix a value and its errors change to null.
Form Control States
Every control, group and form has state properties that change as the user interacts. Use them to decide when to show messages and whether to allow submission.
| Property | true when |
|---|---|
valid | Every rule passes |
invalid | At least one rule fails |
touched | The user focused the field and then left it |
untouched | The user has not left the field yet |
dirty | The user changed the value |
pristine | The user has not changed the value |
pending | An asynchronous validator is still running |
valid and invalid
These report the result of the rules. A form is valid only when every control in it is valid.
touched and untouched
These report whether the user has visited the field. A field becomes touched on blur, when focus leaves it.
dirty and pristine
These report whether the user has changed the value. Setting a value from code does not make a control dirty.
pending
pending is true while an asynchronous validator runs, such as a server check that a username is still free. During this time, the control is neither valid nor invalid.
Disable the submit button while the form is pending as well as invalid. Otherwise the user can submit before the server check has finished.
Control states change as you use the field
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<input [formControl]="email" placeholder="Enter an email" />
<table border="1" cellpadding="4">
<tr><td>valid</td><td>{{ email.valid }}</td><td>invalid</td><td>{{ email.invalid }}</td></tr>
<tr><td>touched</td><td>{{ email.touched }}</td><td>untouched</td><td>{{ email.untouched }}</td></tr>
<tr><td>dirty</td><td>{{ email.dirty }}</td><td>pristine</td><td>{{ email.pristine }}</td></tr>
<tr><td>pending</td><td>{{ email.pending }}</td><td>status</td><td>{{ email.status }}</td></tr>
</table>
`,
})
export class App {
email = new FormControl('', [Validators.required, Validators.email]);
}
Click in the field, type an address and click outside. Watch each state change.
Validation Error Messages
Good messages appear at the right time and say exactly how to fix the value.
- Show messages after the user has interacted, usually when the control is
touchedordirty. A form full of red errors before the user types anything feels hostile. - Show one message per failed rule, using
hasError('ruleName'). - Use the error details in the text, such as the required length.
- Show all messages on submit. Call
form.markAllAsTouched()when the user submits an invalid form.
Messages appear after interaction and on submit
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<p><input formControlName="password" type="password" placeholder="Password" /></p>
@if (password.touched && password.hasError('required')) {
<p class="error">Password is required.</p>
}
@if (password.touched && password.hasError('minlength')) {
<p class="error">
Password must be at least {{ password.getError('minlength').requiredLength }} characters
(you entered {{ password.getError('minlength').actualLength }}).
</p>
}
<button type="submit">Create account</button>
</form>
<p>{{ status }}</p>
`,
styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
private fb = inject(FormBuilder);
form = this.fb.group({ password: ['', [Validators.required, Validators.minLength(8)]] });
status = '';
get password() {
return this.form.controls.password;
}
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
this.status = 'Please fix the errors above.';
return;
}
this.status = 'Account created';
}
}
Click Create account without typing. markAllAsTouched() reveals the message.
Custom Validators
A custom validator is a function that checks a rule the built-in validators do not cover.
The function receives the control and returns:
nullwhen the value is valid.- An error object, such as
{ noSpaces: true }, when it is invalid. The key becomes the error name you check withhasError('noSpaces').
A validator that needs settings, such as a list of forbidden words, is written as a function that returns a validator function.
Custom validators check rules of your own
import { Component, inject } from '@angular/core';
import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn } from '@angular/forms';
export function noSpaces(control: AbstractControl): ValidationErrors | null {
return String(control.value ?? '').includes(' ') ? { noSpaces: true } : null;
}
export function forbiddenWords(words: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = String(control.value ?? '').toLowerCase();
const found = words.find((w) => value.includes(w));
return found ? { forbidden: { word: found } } : null;
};
}
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input formControlName="username" />
</form>
@if (form.controls.username.hasError('noSpaces')) { <p class="error">Username cannot contain spaces.</p> }
@if (form.controls.username.hasError('forbidden')) {
<p class="error">"{{ form.controls.username.getError('forbidden').word }}" is not allowed in a username.</p>
}
`,
styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
private fb = inject(FormBuilder);
form = this.fb.group({
username: ['admin user', [noSpaces, forbiddenWords(['admin', 'root'])]],
});
}
The starting value breaks both rules, so both messages appear. Change it to asha_k and both disappear.
Cross-Field Validation
Cross-field validation checks a rule that involves more than one field, such as "confirm password must match password" or "end date must be after start date".
Put the validator on the group, not on a single control. The function receives the group and can read every control in it. The error is stored on the group, so check it with form.hasError(...).
A group validator compares two fields
import { Component, inject } from '@angular/core';
import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors } from '@angular/forms';
export function passwordsMatch(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return password === confirm ? null : { mismatch: true };
}
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<p><input formControlName="password" placeholder="Password" /></p>
<p><input formControlName="confirm" placeholder="Confirm password" /></p>
</form>
@if (form.hasError('mismatch')) {
<p class="error">Passwords do not match.</p>
} @else {
<p class="ok">Passwords match.</p>
}
`,
styles: `.error { color: #dc2626; } .ok { color: #16a34a; }`,
})
export class App {
private fb = inject(FormBuilder);
form = this.fb.group(
{ password: ['Secret#2026'], confirm: ['Secret#2025'] },
{ validators: passwordsMatch },
);
}
Real-Time Validation
By default, Angular validates on every change, so messages update as the user types. You can change when a control updates and validates with the updateOn option:
updateOn | Validates | Use it for |
|---|---|---|
'change' (default) | On every key press | Most fields, live feedback |
'blur' | When the user leaves the field | Expensive checks, fewer distracting messages |
'submit' | When the form is submitted | Very long forms, server checks |
Compare validation on change and on blur
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<p>On change: <input [formControl]="live" /> {{ live.valid ? '✅' : '❌' }}</p>
<p>On blur: <input [formControl]="onBlur" /> {{ onBlur.valid ? '✅' : '❌' }}</p>
<p>Type a valid email in each field, then click outside the second one.</p>
`,
})
export class App {
live = new FormControl('', [Validators.required, Validators.email]);
onBlur = new FormControl('', { validators: [Validators.required, Validators.email], updateOn: 'blur' });
}
Conditional Validation
Conditional validation applies a rule only when another value makes it necessary. For example, a GST number is required only for business accounts.
Add or remove validators at run time, then call updateValueAndValidity() so Angular checks the control again with the new rules.
A rule that applies only for business accounts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<label><input type="radio" formControlName="type" value="personal" (change)="updateGstRule()" /> Personal</label>
<label><input type="radio" formControlName="type" value="business" (change)="updateGstRule()" /> Business</label>
<p><input formControlName="gst" placeholder="GST number" /></p>
</form>
<p>GST required: {{ form.controls.gst.hasValidator(required) }} · Form valid: {{ form.valid }}</p>
`,
})
export class App {
private fb = inject(FormBuilder);
required = Validators.required;
form = this.fb.group({ type: ['personal'], gst: [''] });
updateGstRule() {
const gst = this.form.controls.gst;
if (this.form.controls.type.value === 'business') {
gst.addValidators(Validators.required);
} else {
gst.removeValidators(Validators.required);
}
gst.updateValueAndValidity();
}
}
Select Business and the empty GST field makes the form invalid. Select Personal and the rule is removed.
Form Validation Best Practices
- Validate on the server too. Client-side rules improve the experience but can be bypassed.
- Show messages after interaction, using
touchedordirty, and reveal all of them on submit withmarkAllAsTouched(). - Write specific messages that say how to fix the value, such as "at least 8 characters".
- Disable submit, or explain why it failed. Either keep the button disabled while the form is invalid, or allow the click and show every error.
- Put cross-field rules on the group, not on one of the fields.
- Keep custom validators pure. They should only read the value and return a result.
- Call
updateValueAndValidity()after adding or removing validators. - Use
updateOn: 'blur'for fields where messages while typing would distract the user.
Common Mistakes
| Mistake | What you see | Fix |
|---|---|---|
| Showing errors before interaction | Red messages on a fresh, empty form | Check touched or dirty before showing them |
Wrong error key casing: hasError('minLength') | The message never appears | Error keys are lower case: 'minlength', 'maxlength' |
| Cross-field rule on one control | The rule does not re-run when the other field changes | Put the validator on the FormGroup |
Validator returns false when valid | The control is always invalid | Return null when the value is valid |
| Adding a validator without re-checking | The control keeps its old status | Call updateValueAndValidity() |
| Relying only on client validation | Bad data reaches the server through direct API calls | Validate again on the server |
Check Yourself
Exercises
What to Remember
- Validators check user input. Template-driven forms use attributes such as
required. Reactive forms useValidatorsfunctions in the class. - Read the result with
valid,invalid,touched,dirty,hasError()andgetError(), and show specific messages after the user interacts. - Custom validators return
nullor an error object. Cross-field rules go on theFormGroup, and conditional rules are added or removed at run time.
Next: Services, how to share data and logic between components with Angular services.