Built-in validators (required, email, minLength) cover simple rules. Real forms also need your own: "the username must be free", "the end date must be after the start date", "the discount code must start with a given prefix". This lesson covers all of them.
What you will learn
- Custom validators, with and without parameters
- Using a service inside a validator
- Async validators and the
PENDINGstate - Cross-field validators on a group
setValidators()and custom validators for template-driven forms
What a validator is
A synchronous validator is a function that receives a control and returns null when the value is valid, or an error object when it is not:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const noSpaces: ValidatorFn = (control: AbstractControl): ValidationErrors | null =>
/\s/.test(control.value ?? '') ? { noSpaces: true } : null;
The key noSpaces becomes the error name, shown in control.errors?.['noSpaces'] and checked with control.hasError('noSpaces').
Validators with parameters
To pass parameters, write a function that returns a validator:
// src/app/forms4/validators.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn } from '@angular/forms';
import { Observable, delay, map, of } from 'rxjs';
// Simple rule: no spaces
export const noSpaces: ValidatorFn = (control) =>
/\s/.test(control.value ?? '') ? { noSpaces: true } : null;
// With a parameter: value must start with a prefix
export function startsWith(prefix: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value ?? '';
if (value === '') {
return null; // let `required` deal with empty values
}
return value.startsWith(prefix) ? null : { startsWith: { required: prefix, actual: value } };
};
}
// Cross-field: runs on the GROUP and compares two controls
export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return password && confirm && password !== confirm ? { mismatch: true } : null;
};
// Date range: end must be after start
export const endAfterStart: ValidatorFn = (group) => {
const start = group.get('start')?.value;
const end = group.get('end')?.value;
return start && end && end < start ? { endBeforeStart: true } : null;
};
// Async: returns an Observable. Pretend this asks a server.
export function usernameFree(taken: string[]): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const name = String(control.value ?? '').toLowerCase();
if (name.length < 3) {
return of(null);
}
return of(taken.includes(name)).pipe(
delay(300), // network time
map((isTaken) => (isTaken ? { usernameTaken: true } : null)),
);
};
}
Notes:
- Return
nullfor empty values and letValidators.requiredhandle emptiness, so users do not see two messages for one problem. - The error value can carry details (
{ required: 'ABC', actual: 'xyz' }) to show in the message. - A validator on a group sees all controls and can compare them. Its error appears on the group (
form.errors), not on a single control.
Using a service in a validator
A validator is a plain function, so it cannot use inject() on its own. Create it inside a class that has the service, and let the function close over it:
// src/app/forms4/username.service.ts
import { Injectable } from '@angular/core';
import { Observable, delay, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UsernameService {
private readonly taken = ['admin', 'root', 'ada'];
isTaken(name: string): Observable<boolean> {
return of(this.taken.includes(name.toLowerCase())).pipe(delay(300));
}
}
// src/app/forms4/signup-form.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { map } from 'rxjs';
import { UsernameService } from './username.service';
import { endAfterStart, noSpaces, passwordsMatch, startsWith } from './validators';
@Component({
selector: 'app-signup-form',
imports: [ReactiveFormsModule, JsonPipe],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<p>
<label for="username">Username</label><br />
<input id="username" formControlName="username" />
@if (username.pending) { <span role="status">Checking...</span> }
@if (username.touched && username.hasError('usernameTaken')) { <span role="alert">That name is taken.</span> }
@if (username.touched && username.hasError('noSpaces')) { <span role="alert">No spaces, please.</span> }
</p>
<p>
<label for="code">Invite code (starts with INV-)</label><br />
<input id="code" formControlName="code" />
@if (form.controls.code.touched && form.controls.code.errors?.['startsWith']; as e) {
<span role="alert">Must start with {{ e.required }}.</span>
}
</p>
<p>
<label for="password">Password</label><br />
<input id="password" type="password" formControlName="password" />
</p>
<p>
<label for="confirm">Confirm password</label><br />
<input id="confirm" type="password" formControlName="confirm" />
@if (form.touched && form.hasError('mismatch')) { <span role="alert">Passwords do not match.</span> }
</p>
<p>
<label><input type="checkbox" formControlName="business" /> I am a business customer</label>
</p>
@if (form.controls.business.value) {
<p>
<label for="vat">VAT number</label><br />
<input id="vat" formControlName="vat" />
</p>
}
<button type="submit" [disabled]="form.invalid || form.pending">Create account</button>
</form>
<pre>{{ form.status }} {{ form.errors | json }}</pre>
`,
})
export class SignupForm {
private readonly fb = inject(FormBuilder).nonNullable;
private readonly usernames = inject(UsernameService);
// The validator is created here, so it can use the injected service.
private readonly usernameAvailable: AsyncValidatorFn = (control: AbstractControl) =>
this.usernames.isTaken(String(control.value ?? '')).pipe(map((taken) => (taken ? { usernameTaken: true } : null)));
protected readonly form = this.fb.group(
{
username: this.fb.control('', {
validators: [Validators.required, Validators.minLength(3), noSpaces],
asyncValidators: [this.usernameAvailable], // run only when the sync validators pass
updateOn: 'blur', // check when the user leaves the field, not on every key
}),
code: ['', [Validators.required, startsWith('INV-')]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirm: ['', Validators.required],
business: [false],
vat: [''],
},
{ validators: [passwordsMatch] }, // group-level validator
);
protected get username() {
return this.form.controls.username;
}
constructor() {
// Change the rules at run time: VAT is required only for business customers.
this.form.controls.business.valueChanges.subscribe((isBusiness) => {
const vat = this.form.controls.vat;
if (isBusiness) {
vat.setValidators([Validators.required, Validators.minLength(8)]);
} else {
vat.clearValidators();
vat.setValue('');
}
vat.updateValueAndValidity(); // IMPORTANT: re-run validation after changing validators
});
}
protected submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('Created', this.form.getRawValue());
}
}
// src/app/app.ts
import { Component } from '@angular/core';
import { SignupForm } from './forms4/signup-form';
@Component({
imports: [SignupForm],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Validation</h1>
<app-signup-form />
</main>
The key ideas
- Async validators return an Observable or Promise. While they run, the control's status is `PENDING`, so you can show "Checking..." and keep the submit button disabled (
form.pending). - Async validators run only after all sync validators pass, which saves server calls.
- `updateOn: 'blur'` (or
'submit') delays validation until the user finishes, which avoids a server call on every keystroke. You can also debounce inside the async validator. - `setValidators()` replaces the validators of a control at run time. It does not re-check the value, so call `updateValueAndValidity()` afterwards.
addValidators(),removeValidators()andclearValidators()also exist. - Group validators go in the second argument of
group(). The error is on the group, so read it withform.hasError('mismatch'). - To show a cross-field error on a control, use the group error in the template, or set it on the control with
setErrors().
Custom validators in template-driven forms
Template-driven forms cannot take a function directly in the template, so wrap the validator in a directive that provides NG_VALIDATORS:
// src/app/forms4/no-spaces.directive.ts
import { Directive } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
import { noSpaces } from './validators';
@Directive({
selector: '[appNoSpaces]',
providers: [{ provide: NG_VALIDATORS, useExisting: NoSpacesDirective, multi: true }],
})
export class NoSpacesDirective implements Validator {
validate(control: AbstractControl): ValidationErrors | null {
return noSpaces(control);
}
}
// src/app/forms4/td-form.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NoSpacesDirective } from './no-spaces.directive';
@Component({
selector: 'app-td-form',
imports: [FormsModule, NoSpacesDirective],
template: `
<form #f="ngForm">
<label for="nick">Nickname</label>
<input id="nick" name="nick" ngModel appNoSpaces #nick="ngModel" />
@if (nick.touched && nick.hasError('noSpaces')) { <span role="alert">No spaces allowed.</span> }
<p>Form valid: {{ f.valid }}</p>
</form>
`,
})
export class TdForm {}
For a validator with a parameter in a template-driven form, add an input() to the directive, and read it in validate. An async validator directive provides NG_ASYNC_VALIDATORS and implements AsyncValidator.
Tests
// src/app/forms4/validators.spec.ts
import { FormControl, FormGroup } from '@angular/forms';
import { endAfterStart, noSpaces, passwordsMatch, startsWith, usernameFree } from './validators';
describe('custom validators', () => {
it('noSpaces rejects spaces', () => {
expect(noSpaces(new FormControl('a b'))).toEqual({ noSpaces: true });
expect(noSpaces(new FormControl('ab'))).toBeNull();
});
it('startsWith takes a parameter and ignores empty values', () => {
const v = startsWith('INV-');
expect(v(new FormControl('INV-1'))).toBeNull();
expect(v(new FormControl(''))).toBeNull();
expect(v(new FormControl('X-1'))).toEqual({ startsWith: { required: 'INV-', actual: 'X-1' } });
});
it('passwordsMatch reports a group error', () => {
const group = new FormGroup({ password: new FormControl('a'), confirm: new FormControl('b') });
expect(passwordsMatch(group)).toEqual({ mismatch: true });
group.get('confirm')!.setValue('a');
expect(passwordsMatch(group)).toBeNull();
});
it('endAfterStart compares dates in a group', () => {
const g = new FormGroup({ start: new FormControl('2026-10-10'), end: new FormControl('2026-10-01') });
expect(endAfterStart(g)).toEqual({ endBeforeStart: true });
});
it('usernameFree is async and reports taken names', async () => {
const control = new FormControl('admin');
const result = await new Promise((resolve) => (usernameFree(['admin'])(control) as any).subscribe(resolve));
expect(result).toEqual({ usernameTaken: true });
});
it('setValidators needs updateValueAndValidity', () => {
const c = new FormControl('');
expect(c.valid).toBe(true);
c.setValidators([(x) => (x.value ? null : { required: true })]);
expect(c.valid).toBe(true); // not re-checked yet
c.updateValueAndValidity();
expect(c.valid).toBe(false);
});
});
The last test shows why updateValueAndValidity() is needed after setValidators().
Try it yourself
- Write
maxWords(n), a validator with a parameter that limits the word count. - Make
endAfterStartshow its error under the end date input. - Add a debounce to the async validator with
timer(300).pipe(switchMap(...)).
Quick check
- What does a validator return when the value is valid?
- Why does
setValidators()needupdateValueAndValidity()? - Where does the error of a group validator appear?
Next, continue with Angular Routing Basics: Pages, RouterLink and RouterOutlet.