Angular course ยท Module 8: Forms

Validation - a Function With One Verdict

6 min read ยท
In this lesson3

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.

import { Validators } from '@angular/forms';

this.fb.group({
  name: ['', Validators.required],
  email: ['', [Validators.required, Validators.email]],
  password: ['', [Validators.required, Validators.minLength(8)]],
  age: [0, [Validators.min(18), Validators.max(120)]],
  phone: ['', Validators.pattern(/^\d{9}$/)],
  acceptTerms: [false, Validators.requiredTrue]
});

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.

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

function forbiddenNameValidator(forbidden: string[]): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if (forbidden.includes(control.value?.toLowerCase())) {
      return { forbiddenName: { value: control.value } };  // invalid -> errors object
    }
    return null;  // OK
  };
}

// Usage
this.fb.group({
  name: ['', [Validators.required, forbiddenNameValidator(['admin', 'root'])]]
});

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:

function strongPasswordValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value;
    if (!value) return null;

    const hasUpperCase = /[A-Z]/.test(value);
    const hasLowerCase = /[a-z]/.test(value);
    const hasNumeric = /[0-9]/.test(value);
    const hasSpecial = /[!@#$%^&*]/.test(value);

    const isValid = hasUpperCase && hasLowerCase && hasNumeric && hasSpecial;

    return isValid ? null : {
      weakPassword: {
        hasUpperCase,
        hasLowerCase,
        hasNumeric,
        hasSpecial
      }
    };
  };
}

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.

import { AsyncValidatorFn } from '@angular/forms';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';
import { of } from 'rxjs';

function uniqueNameValidator(service: SamuraiService): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return of(control.value).pipe(
      debounceTime(300),                       // do not ask after every letter
      switchMap(name => service.checkNameExists(name).pipe(
        map(exists => exists ? { nameTaken: true } : null),
        catchError(() => of(null))             // network error -> do not block
      )),
      first()
    );
  };
}

// Async validators are the THIRD argument, separate from the synchronous ones
this.fb.group({
  name: ['', [Validators.required], [uniqueNameValidator(this.service)]]
});

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.

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What should a custom validator return when the value is valid?

  2. 2. What should a custom validator return when the value is INVALID?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the custom validator that checks the samurai name does not contain digits.

  • Horizontal ordering

    Arrange the tokens of the returned validator function (one line): (control: AbstractControl) => ValidationErrors | null

  • Vertical ordering

    Order the RxJS operators in the async validator chain that checks name uniqueness (the order inside .pipe(), line by line).

Useful articles