Key Takeaways
- Template-driven and reactive forms both support built-in, custom, cross-field, and asynchronous validation. Stay consistent within an individual form.
- Show errors after interaction or submission, use null-safe error checks, and associate messages with their inputs.
- Attach field rules to controls and cross-field rules to their common parent group.
- Async validation has a separate pending state. Handle lookup failures explicitly and enforce all authoritative rules on the server.
Angular form validation works by attaching validators to form controls or groups. Template-driven forms configure those rules through directives; reactive forms construct the form model in application code and attach validator functions directly. Both approaches support the same progression: built-in checks, custom rules, cross-field rules, and asynchronous checks.
If you need the underlying form concepts first, read our comparison of Angular template-driven and reactive forms.
Setup and Choosing an Approach
These examples target Angular 22.2, standalone components, strict TypeScript, and Angular's built-in @if template syntax. Create a workspace with the published Angular CLI 22.2.0 release, using a Node.js version supported by Angular's compatibility table:
npx @angular/cli@22.2.0 new validation-demo --standalone --strict --routing=false --ssr=false --style=css --defaults
cd validation-demo
rm src/app/app.spec.ts
npx ng serveThe CLI options select a standalone, strict application without routing or server rendering. Keep the generated lockfile to reproduce dependency installation. Each later section is an independent variation of the corresponding basic example, not a requirement to accumulate every validator.
The command removes the generated component test because these examples replace that component, while retaining the test-runner dependencies and test target needed for the validator tests below.
| Decision | Template-driven forms | Reactive forms |
|---|---|---|
| Required component import | FormsModule | ReactiveFormsModule |
| Form model | Created through NgForm and registered NgModel controls | Constructed explicitly with controls and groups |
| Custom validation | A validator directive wraps reusable validation logic | Attach the reusable function directly |
| Good fit | Small forms with straightforward template-oriented configuration | Forms needing explicit composition, typed models, and direct model tests |
Both can coexist in one application; don't mix their control-binding strategies within one form. Angular also offers Signal Forms, a separate form model described in its forms comparison. This tutorial focuses on the template-driven and reactive APIs.
For either basic example, replace src/main.ts with:
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app';
bootstrapApplication(AppComponent).catch((error: unknown) => {
console.error('Application bootstrap failed', error);
});Built-In Angular Form Validation Examples
Angular matches attributes such as required and minlength with validator directives. Reactive forms can instead configure the corresponding Validators functions. Angular disables native browser validation UI by default; its validation state and error messages are distinct from browser validation bubbles. See the official validation guide.
Template-Driven Form Validation
FormsModule supplies the template-driven directives. NgForm creates the parent group, while named NgModel controls register with it.
src/app/app.ts
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule],
templateUrl: './app.html',
})
export class AppComponent {
user = { name: '' };
message = '';
onSubmit(form: NgForm): void {
form.form.markAllAsTouched();
if (!form.valid) return;
this.message = 'Demo submission accepted.';
}
}src/app/app.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<label for="name">Name</label>
<input id="name" name="name" type="text"
[(ngModel)]="user.name" #name="ngModel"
required minlength="2"
aria-describedby="name-errors"
[attr.aria-invalid]="name.invalid && (name.touched || userForm.submitted)">
<div id="name-errors" aria-live="polite">
@if (name.invalid && (name.touched || userForm.submitted)) {
@if (name.hasError('required')) {
<p>Name is required.</p>
}
@if (name.hasError('minlength')) {
<p>Name must contain at least two characters.</p>
}
}
</div>
<button type="submit" [disabled]="userForm.pending === true">Submit</button>
</form>
<p role="status">{{ message }}</p>Typed Reactive Form Validation
Typed reactive forms preserve the value types of your controls. Here, nonNullable: true keeps the name control's value typed as string and makes a parameterless reset restore its initial value rather than null.
src/app/app.ts
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
FormControl, FormGroup, ReactiveFormsModule, Validators,
} from '@angular/forms';
@Component({
selector: 'app-root',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './app.html',
})
export class AppComponent {
readonly name = new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(2)],
});
readonly userForm = new FormGroup({ name: this.name });
readonly formStatus = toSignal(this.userForm.statusChanges, {
initialValue: this.userForm.status,
});
submitted = false;
message = '';
onSubmit(): void {
this.submitted = true;
this.userForm.markAllAsTouched();
if (!this.userForm.valid) return;
this.message = 'Demo submission accepted.';
}
}src/app/app.html
<form [formGroup]="userForm" (ngSubmit)="onSubmit()"
[attr.aria-busy]="formStatus() === 'PENDING'">
<label for="name">Name</label>
<input id="name" type="text" formControlName="name" required
aria-describedby="name-errors"
[attr.aria-invalid]="name.invalid && (name.touched || submitted)">
<div id="name-errors" aria-live="polite">
@if (name.invalid && (name.touched || submitted)) {
@if (name.hasError('required')) {
<p>Name is required.</p>
}
@if (name.hasError('minlength')) {
<p>Name must contain at least two characters.</p>
}
}
</div>
<button type="submit" [disabled]="userForm.pending">Submit</button>
</form>
<p role="status">{{ message }}</p>The form model is explicit; reusable business rules needn't live inside the component. The formStatus signal also connects status changes to rendering, which matters when async validation finishes in a zoneless application.
Angular Form Validation On Submit
Delaying error messages isn't the same as delaying validation. The basic examples validate normally but reveal errors after a field is touched or the form is submitted. Their handlers mark every control touched and refuse submission unless the form is valid.
The control-state API distinguishes these states:
touched: the user has blurred the control, or code has marked it touched.dirty: the user has changed its value through the UI.validandinvalid: the status isVALIDorINVALID, respectively.pending: asynchronous validation is running. Pending doesn't mean valid.
To reveal messages while editing, add dirty to the presentation condition. To actually defer view-to-model updates and validation until submission, configure updateOn: 'submit'; the default is 'change'.
<!-- Fragment: add this option to the template-driven form. -->
<form #userForm="ngForm" [ngFormOptions]="{ updateOn: 'submit' }"
(ngSubmit)="onSubmit(userForm)">
<!-- Keep the inputs and submit button from the basic example. -->
</form>// Fragment: replace the reactive example's userForm initializer.
class AppComponent {
// ... Keep the name field and other members from the basic example.
readonly userForm = new FormGroup(
{ name: this.name },
{ updateOn: 'submit' },
);
}A control-level updateOn overrides the inherited group setting. Don't disable Submit just because the form is invalid when submission is what reveals errors or commits pending input. With async checks, a submission that starts validation may need another submission after the check finishes; these handlers don't queue an automatic save.
Keep feedback accessible: aria-describedby associates each input with its messages, while aria-invalid exposes the displayed invalid state. For a long form, also focus an error summary or the first invalid field after a failed submission.
Custom Validator Example: Exact Forbidden Names
A synchronous validator returns ValidationErrors | null: an error map when validation fails, otherwise null. Use stable, distinct error keys so messages can identify each rule. Arrays of validators compose their results; Validators also exposes composition helpers.
This teaching rule rejects Bob and Joe, ignoring case and surrounding whitespace, but allows Joseph. It compares complete names rather than testing an unanchored regular expression.
Reusable Function and Template-Driven Directive
src/app/invalid-entry.directive.ts
import { Directive, forwardRef, Input } from '@angular/core';
import {
AbstractControl, NG_VALIDATORS, ValidationErrors, Validator, ValidatorFn,
} from '@angular/forms';
export function invalidEntryValidator(names: readonly string[]): ValidatorFn {
const forbidden = new Set(names.map((name) => name.trim().toLowerCase()));
return (control: AbstractControl): ValidationErrors | null => {
const value: unknown = control.value;
if (typeof value !== 'string') return null;
return forbidden.has(value.trim().toLowerCase())
? { invalidEntry: { value } }
: null;
};
}
@Directive({
selector: '[appInvalidEntry]',
standalone: true,
providers: [{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => InvalidEntryDirective),
multi: true,
}],
})
export class InvalidEntryDirective implements Validator {
private validator: ValidatorFn = () => null;
private validatorChanged: () => void = () => {};
@Input('appInvalidEntry')
set forbiddenNames(names: readonly string[]) {
this.validator = invalidEntryValidator(names);
this.validatorChanged();
}
validate(control: AbstractControl): ValidationErrors | null {
return this.validator(control);
}
registerOnValidatorChange(fn: () => void): void {
this.validatorChanged = fn;
}
}The Validator contract includes registerOnValidatorChange. Calling its callback when the input changes tells Angular to reevaluate the rule.
In the basic template-driven component, import InvalidEntryDirective from './invalid-entry.directive' and use imports: [FormsModule, InvalidEntryDirective]. Add [appInvalidEntry]="['Bob', 'Joe']" to its existing input.
Reactive Integration and Error Display
import { invalidEntryValidator } from './invalid-entry.directive';
// Fragment: replace AppComponent.name in the basic reactive example.
class AppComponent {
readonly name = new FormControl<string>('', {
nonNullable: true,
validators: [
Validators.required,
Validators.minLength(2),
invalidEntryValidator(['Bob', 'Joe']),
],
});
// ... Keep the other members from the basic example.
}For either approach, add this check inside the existing, interaction-gated name-errors block:
<!-- Fragment: inside the existing error-display condition. -->
@if (name.hasError('invalidEntry')) {
<p>Bob and Joe aren't allowed in this demonstration.</p>
}Cross-Field Validation Example
A rule comparing sibling fields belongs on their common parent group. Its error is therefore read from the group, not an unrelated child control. We'll reject matching first and last names as a teaching example—not a sensible universal restriction on people's names.
Separate Group Validator and Directive
src/app/matching-inputs.directive.ts
import { Directive, forwardRef } from '@angular/core';
import {
AbstractControl, NG_VALIDATORS, ValidationErrors, Validator, ValidatorFn,
} from '@angular/forms';
export const matchingInputsValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const first: unknown = control.get('first')?.value;
const last: unknown = control.get('last')?.value;
if (typeof first !== 'string' || typeof last !== 'string') return null;
const left = first.trim().toLowerCase();
const right = last.trim().toLowerCase();
return left !== '' && right !== '' && left === right
? { matchingInputs: true }
: null;
};
@Directive({
selector: '[appMatchingInputs]',
standalone: true,
providers: [{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => MatchingInputsDirective),
multi: true,
}],
})
export class MatchingInputsDirective implements Validator {
validate(control: AbstractControl): ValidationErrors | null {
return matchingInputsValidator(control);
}
}Template-Driven Cross-Field Validation
Start from the basic template-driven component. Initialize user = { first: '', last: '' }, import MatchingInputsDirective from './matching-inputs.directive', and use imports: [FormsModule, MatchingInputsDirective]. Keep its typed submit handler and replace the template:
<form #userForm="ngForm" appMatchingInputs (ngSubmit)="onSubmit(userForm)">
<label for="first">First name</label>
<input id="first" name="first" [(ngModel)]="user.first" #first="ngModel"
required minlength="2" aria-describedby="first-errors names-error"
[attr.aria-invalid]="(first.invalid || userForm.hasError('matchingInputs'))
&& (first.touched || userForm.submitted)">
<div id="first-errors">
@if (first.invalid && (first.touched || userForm.submitted)) {
<p>First name is required and must contain at least two characters.</p>
}
</div>
<label for="last">Last name (optional)</label>
<input id="last" name="last" [(ngModel)]="user.last"
aria-describedby="names-error"
[attr.aria-invalid]="userForm.hasError('matchingInputs')
&& (userForm.touched || userForm.submitted)">
<div id="names-error" aria-live="polite">
@if (userForm.hasError('matchingInputs') &&
(userForm.touched || userForm.submitted)) {
<p>First and last names must differ in this demonstration.</p>
}
</div>
<button type="submit">Submit</button>
</form>
<p role="status">{{ message }}</p>Reactive Cross-Field Validation
Start from the basic reactive component. Replace its name and userForm fields with the following; keep formStatus and the submit handler below them:
import { matchingInputsValidator } from './matching-inputs.directive';
// Fragment: replacement fields inside AppComponent.
class AppComponent {
readonly first = new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(2)],
});
readonly last = new FormControl<string>('', { nonNullable: true });
readonly userForm = new FormGroup(
{ first: this.first, last: this.last },
{ validators: matchingInputsValidator },
);
get matchingNames(): boolean {
return this.userForm.hasError('matchingInputs')
&& (this.userForm.touched || this.submitted);
}
// ... Keep formStatus and the other members from the basic example.
}<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<label for="first">First name</label>
<input id="first" formControlName="first" required
aria-describedby="first-errors names-error"
[attr.aria-invalid]="matchingNames || (first.invalid && (first.touched || submitted))">
<div id="first-errors">
@if (first.invalid && (first.touched || submitted)) {
<p>First name is required and must contain at least two characters.</p>
}
</div>
<label for="last">Last name (optional)</label>
<input id="last" formControlName="last" aria-describedby="names-error"
[attr.aria-invalid]="matchingNames">
<div id="names-error" aria-live="polite">
@if (matchingNames) {
<p>First and last names must differ in this demonstration.</p>
}
</div>
<button type="submit">Submit</button>
</form>
<p role="status">{{ message }}</p>Async Validator Example: Username Availability
An AsyncValidatorFn returns a promise or observable containing an error map or null. Angular runs async validation only after synchronous validators pass, and the observable must complete. For an enabled individual control, the lifecycle is:
View update: change / blur / submit
|
Synchronous validators -- errors --> INVALID
|
no errors
|
Async configured? -- no --> VALID
|
yes
|
PENDING
|
Async result --> VALID / INVALIDConcrete Service Contract and Mock Responses
This mock makes the example runnable without a backend. Bob and Joe are taken; offline simulates a failed lookup; other names are available. It responds after a simulated 400-millisecond delay.
src/app/user.service.ts
import { Injectable } from '@angular/core';
import { map, Observable, timer } from 'rxjs';
export interface NameLookup {
nameExists(name: string): Observable<boolean>;
}
@Injectable({ providedIn: 'root' })
export class UserService implements NameLookup {
private readonly taken = new Set(['bob', 'joe']);
nameExists(name: string): Observable<boolean> {
const normalized = name.trim().toLowerCase();
return timer(400).pipe(
map(() => {
if (normalized === 'offline') {
throw new Error('Simulated lookup failure');
}
return this.taken.has(normalized);
}),
);
}
}src/app/available-name.validator.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { catchError, map, Observable, of, take } from 'rxjs';
import type { NameLookup } from './user.service';
export function availableName(users: NameLookup) {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const value: unknown = control.value;
if (typeof value !== 'string' || value.length === 0) return of(null);
return users.nameExists(value).pipe(
map((exists): ValidationErrors | null =>
exists ? { nameExists: true } : null),
catchError(() => of<ValidationErrors>({ availabilityUnavailable: true })),
take(1),
);
};
}The returned function satisfies the async-validator contract. This example deliberately blocks submission when availability can't be checked, using availabilityUnavailable rather than pretending the name is taken. The error handler returns an observable, not bare null.
Reactive Async Validation
Use the basic reactive example, without the forbidden-name variation. Inject the service before constructing the control, and attach the async validator to name, not the whole group:
import { inject } from '@angular/core';
import { availableName } from './available-name.validator';
import { UserService } from './user.service';
// Fragment: replacement fields inside AppComponent, before userForm.
class AppComponent {
private readonly users = inject(UserService);
readonly name = new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(2)],
asyncValidators: [availableName(this.users)],
updateOn: 'blur',
});
// ... Keep userForm and the other members from the basic example.
}Keep the basic userForm and formStatus fields. The latter makes async status changes visible without relying on Zone.js.
Template-Driven Async Validation
An async directive registers under NG_ASYNC_VALIDATORS, not NG_VALIDATORS. The AsyncValidator interface defines its return contract.
src/app/available-name.directive.ts
import {
ChangeDetectorRef, Directive, forwardRef, inject,
} from '@angular/core';
import {
AbstractControl, AsyncValidator, NG_ASYNC_VALIDATORS, ValidationErrors,
} from '@angular/forms';
import { Observable, tap } from 'rxjs';
import { availableName } from './available-name.validator';
import { UserService } from './user.service';
@Directive({
selector: '[appAvailableName]',
standalone: true,
providers: [{
provide: NG_ASYNC_VALIDATORS,
useExisting: forwardRef(() => AvailableNameDirective),
multi: true,
}],
})
export class AvailableNameDirective implements AsyncValidator {
private readonly check = availableName(inject(UserService));
private readonly changeDetector = inject(ChangeDetectorRef);
validate(control: AbstractControl): Observable<ValidationErrors | null> {
return this.check(control).pipe(
tap(() => this.changeDetector.markForCheck()),
);
}
}Import AvailableNameDirective from './available-name.directive' in the basic template-driven component and use imports: [FormsModule, AvailableNameDirective]. Add appAvailableName and [ngModelOptions]="{ updateOn: 'blur' }" to its input. The directive explicitly schedules a view check when a result arrives.
Pending State, Errors, and Request Cancellation
In either basic template, change the label to Username and the input's description to aria-describedby="name-errors name-status name-availability". Add this inside the form:
<!-- Fragment: works with the basic examples' name control/reference. -->
<p id="name-status" role="status">
@if (name.pending) { Checking username availability… }
</p>
<div id="name-availability" aria-live="polite">
@if (name.touched && name.hasError('nameExists')) {
<p>That username is already taken.</p>
}
@if (name.touched && name.hasError('availabilityUnavailable')) {
<p>Couldn't check availability. Edit the username and leave the field to retry.</p>
}
</div>updateOn: 'blur' reduces requests by waiting until the field loses focus. Angular unsubscribes from superseded async-validation subscriptions; with this update strategy, a replacement run follows a committed blur update, not every keystroke.
Return the lookup observable rather than subscribing inside the validator. For a real backend, replace the mock with an HttpClient request preserving the Observable<boolean> contract and configure provideHttpClient() in application providers. Unsubscribing from an in-progress HttpClient request aborts it; a promise doesn't provide equivalent request cancellation. Our Angular HTTP request tutorial covers that integration.
Test Validators Without Rendering a Form
The validation functions accept controls and can be tested directly. Add src/app/validators.spec.ts; new CLI projects use Vitest by default when testing is enabled. Run it with npx ng test --watch=false in the testing-configured workspace created above.
import { FormControl } from '@angular/forms';
import { firstValueFrom, of, throwError } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { invalidEntryValidator } from './invalid-entry.directive';
import { availableName } from './available-name.validator';
describe('name validators', () => {
it('rejects complete forbidden names, not longer names', () => {
const validate = invalidEntryValidator(['Bob', 'Joe']);
for (const value of ['Bob', 'JOE', ' Joe ']) {
expect(validate(new FormControl(value)))
.toEqual({ invalidEntry: { value } });
}
expect(validate(new FormControl('Joseph'))).toBeNull();
expect(validate(new FormControl('Alice'))).toBeNull();
});
it('handles available, taken, and failed lookups', async () => {
const cases = [
{ response: of(false), expected: null },
{ response: of(true), expected: { nameExists: true } },
{
response: throwError(() => new Error('Network unavailable')),
expected: { availabilityUnavailable: true },
},
];
for (const { response, expected } of cases) {
const validate = availableName({ nameExists: () => response });
const result = await firstValueFrom(validate(new FormControl('alice')));
expect(result).toEqual(expected);
}
});
});Also add component tests for blur timing, pending feedback, submit guards, changed directive inputs, and group-error messages. Those interactions are separate from testing the rule itself; see our discussion of the benefits of focused unit tests.
Common Mistakes
- Reading nullable errors directly. Use
hasError('required'), noterrors.required. - Putting a group rule on one field. Compare siblings at their common parent and display the parent error.
- Treating pending as success. Guard submission with
valid; don't assume that not invalid means valid. - Changing validators without recalculating. After adding or removing validators at runtime, call
updateValueAndValidity(). - Leaving async failures unexplained. Choose a deliberate failure policy, provide retry feedback, and set a request timeout in the real service.
- Using availability endpoints carelessly. Rate-limit them and consider whether exposing account existence is appropriate for the workflow.
FAQ
How do I display Angular form validation errors only after a field is touched?
Render messages when the control is invalid and touched, optionally including a submitted flag to reveal untouched errors after submission. Call markAllAsTouched() in the submit handler and use hasError() instead of dereferencing nullable errors.
How do I add a custom validator to an Angular reactive form?
Pass a ValidatorFn in the control's validators option alongside built-in validators. Return null for success or a ValidationErrors object with a stable error key for failure.
How do I validate two fields together in Angular?
Attach the validator to the FormGroup containing both controls and read their values from that group. Return and display the cross-field error on the group rather than placing it on an unrelated field.
How do I write an async username validator and show its pending state?
Return a completing observable or promise that produces null or a validation error, and attach it to the username control. Display the control's pending state while checking, consider updateOn: 'blur' to reduce requests, and distinguish network failures from taken usernames.
What is the difference between NG_VALIDATORS and NG_ASYNC_VALIDATORS?
NG_VALIDATORS registers synchronous validator directives whose validate() method returns errors or null immediately. NG_ASYNC_VALIDATORS registers asynchronous validator directives whose validate() method returns a promise or observable; both provider registrations use multi: true.
Further Reading
- Angular form validation guide — built-in, custom, cross-field, and async validation.
- Typed reactive forms — control types, nullability, and group values.
- AbstractControl API — state, update strategies, and runtime validator changes.
- Validator interface — directive registration and validator-input changes.
- AsyncValidator interface — asynchronous directive contracts.
- Zoneless Angular — rendering notifications for reactive form updates.
- Angular testing guide — the CLI's current testing setup.
- OWASP input validation guidance — authoritative server-side checks and trust boundaries.
