The guard at the dojo gate has a simple job: look at whoever shows up and give a verdict - let them in, or turn them away. A validator in Angular is exactly that kind of function: it looks at the value of a field and returns one of two things -
null when everything is fine, or an object describing the error when something is wrong. Hold on to that contract (null = OK), because it comes back in every one of the three parts of this lesson.The rules you need most often already come with Angular - you attach them to a field as a list. You are not writing any logic here, you are picking guards off the shelf.
1import { Validators } from '@angular/forms';
2
3this.fb.group({
4 name: ['', Validators.required],
5 email: ['', [Validators.required, Validators.email]],
6 password: ['', [Validators.required, Validators.minLength(8)]],
7 age: [0, [Validators.min(18), Validators.max(120)]],
8 phone: ['', Validators.pattern(/^\d{9}$/)],
9 acceptTerms: [false, Validators.requiredTrue]
10});Notice the pattern: when there is a single rule you pass it directly (
Validators.required); when there are several, you pass them in an array - and all of them have to pass. Validators.minLength(8) has a twin, Validators.maxLength(50), for capping the other end. And requiredTrue is the trap worth remembering: plain required treats a ticked and an unticked checkbox alike, as if both were filled in, while requiredTrue demands the tick itself - exactly what you want for accepting the terms.When you need a rule that is not on the shelf, you write the guard yourself. It is a function that honours the same contract: it takes an
AbstractControl and returns null or an errors object. ValidatorFn is the type Angular gives to that function, and you import it from @angular/forms next to AbstractControl and ValidationErrors.1import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
2
3function forbiddenNameValidator(forbidden: string[]): ValidatorFn {
4 return (control: AbstractControl): ValidationErrors | null => {
5 if (forbidden.includes(control.value?.toLowerCase())) {
6 return { forbiddenName: { value: control.value } }; // invalid -> errors object
7 }
8 return null; // OK
9 };
10}
11
12// Usage
13this.fb.group({
14 name: ['', [Validators.required, forbiddenNameValidator(['admin', 'root'])]]
15});Look at the shape. The outer function takes the configuration (the list of forbidden names) and returns the actual validator, so the same guard can serve different lists. The inner function is the one with the signature
(control: AbstractControl) => ValidationErrors | null, and it fulfils the contract: on a match it returns { forbiddenName: ... }, otherwise null. The key of that object (forbiddenName) is the error name you will look for in the template later.Two things this guard never does. It never reports success with
true, with undefined, or with an object like { valid: true } - Angular reads any returned object as a failure, so { valid: true } would flag a perfectly good field as broken. And it never signals a problem with false or by throwing: return false means nothing to Angular, and a thrown error would take the whole form down. Only null, or a ValidationErrors object such as { forbiddenName: true }.The errors object can carry more than a bare flag. A password-strength guard, for example, can hand back exactly which requirements are still missing, so the template can list them one by one:
1function strongPasswordValidator(): ValidatorFn {
2 return (control: AbstractControl): ValidationErrors | null => {
3 const value = control.value;
4 if (!value) return null;
5
6 const hasUpperCase = /[A-Z]/.test(value);
7 const hasLowerCase = /[a-z]/.test(value);
8 const hasNumeric = /[0-9]/.test(value);
9 const hasSpecial = /[!@#$%^&*]/.test(value);
10
11 const isValid = hasUpperCase && hasLowerCase && hasNumeric && hasSpecial;
12
13 return isValid ? null : {
14 weakPassword: {
15 hasUpperCase,
16 hasLowerCase,
17 hasNumeric,
18 hasSpecial
19 }
20 };
21 };
22}Same contract, richer payload:
null when all four conditions hold, otherwise { weakPassword: ... } with the details attached. Notice the empty-value guard at the top - an empty field returns null here, because deciding whether a field may be empty at all belongs to Validators.required, not to this guard. Each validator answers one question and stays out of the others.Some things you simply cannot check on the spot - whether a name is already taken is known only to the server. In that case the validator does not hand back a verdict straight away; it returns a stream that will bring the verdict in a moment.
1import { AsyncValidatorFn } from '@angular/forms';
2import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';
3import { of } from 'rxjs';
4
5function uniqueNameValidator(service: SamuraiService): AsyncValidatorFn {
6 return (control: AbstractControl) => {
7 return of(control.value).pipe(
8 debounceTime(300), // do not ask after every letter
9 switchMap(name => service.checkNameExists(name).pipe(
10 map(exists => exists ? { nameTaken: true } : null),
11 catchError(() => of(null)) // network error -> do not block
12 )),
13 first()
14 );
15 };
16}
17
18// Async validators are the THIRD argument, separate from the synchronous ones
19this.fb.group({
20 name: ['', [Validators.required], [uniqueNameValidator(this.service)]]
21});The contract is the same (
{ nameTaken: true } or null), only wrapped in a stream, because the answer arrives with a delay. Read the pipe from top to bottom and you have the whole story: debounceTime(300) spares the server by asking only once the user stops typing, switchMap sends the question off to the service and abandons any older answer still on its way, map turns the boolean reply into the errors object or null, catchError decides that a network failure must not block the form, and first() closes the stream so Angular knows the verdict is final. And remember the one detail that is easy to get wrong: async validators are passed as the third argument in fb.group, after the initial value and after the synchronous validators - never mixed in with them, because Angular waits for them differently.Take one contract away from this lesson: a validator is a function that returns
null for a valid value, or a ValidationErrors object for an invalid one. The built-in ones you take off the shelf, your own you write by that same rule, and the async ones deliver the verdict through a stream - everything else is detail.