We use cookies to enhance your experience on the site
CodeWorlds

Validation - a Function With One Verdict

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.

Built-in Validators

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.

Custom Validators

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.

Async Validator

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.

Go to CodeWorlds