Custom Form Validators

Learning Objectives

  • Know how the built-in validators work in both the model driven and template driven forms.

  • Know how to create a basic hardcoded custom validator for both model driven and template driven forms.

Built in validators

We have a few built in validators in Angular:

  • required

  • minlength

  • maxlength

  • pattern

We can use these in two ways:

  1. As functions we can pass to the FormControl constructor in model driven forms.

new FormControl('', Validators.required)

The above creates a form control with a required validator function attached

  1. As directives in template driven forms.

<input name="fullName" ngModel required>

These required, minlength, maxlength and pattern attributes are already in the official HTML specification.

They are a core part of HTML and we don’t actually need Angular in order to use them. If they are present in a form then the browser will perform some default validation itself.

However we do need a way for Angular to recognise their presence and support the same validation logic in our own Angular forms.

If you remember template driven forms are just model driven forms but with the creation of the model driven by the template, they still have an underlying model.

Therefore just like model driven forms we need to attach a validator function to the underlying model form control.

Angular does this by secretly creating special validator directives which have selectors matching required, minlength, maxlength and pattern.

So if you have imported FormsModule into your NgModule then anytime Angular sees a required tag in the HTML it will link it to an instance of a directive called RequiredValidator

This directive validator applies the same Validators.required function as we use in model driven forms.

That’s how the built-in validators work, lets try to create our own custom validators that work with both model and template driven forms.

Custom model form validator

Validators at their core are just functions, they take as input a FormControl instance and returns either null if it’s valid or an error object if it’s not.

We’ll create a custom email validator function which only accepts emails on the domain codecraft.tv:

function emailDomainValidator(control: FormControl) { (1)
  let email = control.value; (2)
  if (email && email.indexOf("@") != -1) { (3)
    let [_, domain] = email.split("@"); (4)
    if (domain !== "codecraft.tv") { (5)
      return {
        emailDomain: {
          parsedDomain: domain
        }
      }
    }
  }
  return null; (6)
}
1 Accepts an instance of a FormControl as the first param.
2 We get the email value from the form control.
3 Only bother checking if the email contains an "@" character.
4 Extract the domain part from the email.
5 If the domain is not codecraft.tv then return an error object with some perhaps helpful tips as to why it’s failing.
6 Return null because if we have reached here the validator is passing.

To use this validator in our model driven form we pass it into the FormControl on construction, like so:

1 2 3 4 5
this.email = new FormControl('', [ Validators.required, Validators.pattern("[^ @]*@[^ @]*"), emailDomainValidator ]);

Just like other validators lets add a helpful message to the user if the validator fails so they know how to fix it:

1 2 3 4 5 6
<div class="form-control-feedback" *ngIf="email.errors && (email.dirty || email.touched)"> <p *ngIf="email.errors.required">Email is required</p> <p *ngIf="password.errors.pattern">The email address must contain at least the @ character</p> <p *ngIf="email.errors.emailDomain">Email must be on the codecraft.tv domain</p> (1) </div>
1 The error object returned from the validator function is merged into to the email.errors object so is the key emailDomain is present then we know the emailDomainValidator is failing.

Now if we try to type in an email address that doesn’t end in codecraft.tv we see this validation message printed on screen:

custom validator mdf fail

Next up we’ll look at how we can re-package our validator function for use in template driven forms.

Custom template driven form validator

To use our validator function in a template driven form we need to:

  1. Create a directive and attach it to the template form control.

  2. Provide the directive with the validator function on the token NG_VALIDATORS.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
import {NG_VALIDATORS} from '@angular/forms'; . . . @Directive({ selector: '[emailDomain][ngModel]', (1) providers: [ { provide: NG_VALIDATORS, (2) useValue: emailDomainValidator, (3) multi: true (4) } ] }) class EmailDomainValidator { }
1 Attached to all input controls which have both the emailDomain and ngModel attribute.
2 We provide on the special token NG_VALIDATORS.
3 The emailDomainValidator function we created for the model driven form is the dependency.
4 This provider is a special kind of provider called a multi provider.

Note

Multi providers return multiple dependencies as a list for a given token. So with our provider above we are just adding to the list of dependencies that are returned when we request the NG_VALIDATORS token.

We declare this new directive on our NgModule:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
@NgModule({ imports: [ BrowserModule, FormsModule ], declarations: [ AppComponent, TemplateFormComponent, EmailDomainValidator ], bootstrap: [ AppComponent ], }) class AppModule { }

Finally we add this directive to our template form control like so:

1 2 3 4 5 6 7 8
<input type="email" class="form-control" name="email" [(ngModel)]="model.email" required pattern="[^ @]*@[^ @]*" emailDomain #email="ngModel">

Now just like the model driven form when we type into the email field an email that doesn’t end in codecraft.tv we see the same error:

custom validator tdf fail

Summary

A validator in Angular is a function which returns null if a control is valid or an error object if it’s invalid.

For model driven forms we create custom validation functions and pass them into the FormControl constructor.

For template driven forms we need to create validator directives and provide the validator function to the directive via DI.

Through careful planning we can share the same validation code between the model driven and template driven forms.

The validator we created in this lecture hardcodes the domain, in the next lecture we will look at how we can make our validators configurable with different domains.

Model Driven Listing

Listing 1. script.ts
import {
    NgModule,
    Component,
    Pipe,
    OnInit
} from '@angular/core';
import {
    ReactiveFormsModule,
    FormsModule,
    FormGroup,
    FormControl,
    Validators,
    FormBuilder
} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';

function emailDomainValidator(control: FormControl) {
  let email = control.value;
  if (email && email.indexOf("@") != -1) {
    let [_, domain] = email.split("@");
    if (domain !== "codecraft.tv") {
      return {
        emailDomain: {
          parsedDomain: domain
        }
      }
    }
  }
  return null;
}

@Component({
  selector: 'model-form',
  template: `<form novalidate
      [formGroup]="myform">

  <fieldset formGroupName="name">
    <div class="form-group"
         [ngClass]="{
        'has-danger': firstName.invalid && (firstName.dirty || firstName.touched),
        'has-success': firstName.valid && (firstName.dirty || firstName.touched)
      }">
      <label>First Name</label>
      <input type="text"
             class="form-control"
             formControlName="firstName"
             required>
      <div class="form-control-feedback"
           *ngIf="firstName.errors && (firstName.dirty || firstName.touched)">
        <p *ngIf="firstName.errors.required">First Name is required</p>
      </div>

    </div>

    <div class="form-group"
         [ngClass]="{
        'has-danger': lastName.invalid && (lastName.dirty || lastName.touched),
        'has-success': lastName.valid && (lastName.dirty || lastName.touched)
      }">
      <label>Last Name</label>
      <input type="text"
             class="form-control"
             formControlName="lastName"
             required>
      <div class="form-control-feedback"
           *ngIf="lastName.errors && (lastName.dirty || lastName.touched)">
        <p *ngIf="lastName.errors.required">Last Name is required</p>
      </div>
    </div>
  </fieldset>


  <div class="form-group"
       [ngClass]="{
        'has-danger': email.invalid && (email.dirty || email.touched),
        'has-success': email.valid && (email.dirty || email.touched)
   }">
    <label>Email</label>
    <input type="email"
           class="form-control"
           formControlName="email"
           required>
    <div class="form-control-feedback"
         *ngIf="email.errors && (email.dirty || email.touched)">
      <p *ngIf="email.errors.required">Email is required</p>
      <p *ngIf="password.errors.pattern">The email address must contain at least the @ character</p>
      <p *ngIf="email.errors.emailDomain">Email must be on the codecraft.tv domain</p>
    </div>

  </div>

  <div class="form-group"
       [ngClass]="{
        'has-danger': password.invalid && (password.dirty || password.touched),
        'has-success': password.valid && (password.dirty || password.touched)
   }">
    <label>Password</label>
    <input type="password"
           class="form-control"
           formControlName="password"
           required>
    <div class="form-control-feedback"
         *ngIf="password.errors && (password.dirty || password.touched)">
      <p *ngIf="password.errors.required">Password is required</p>
      <p *ngIf="password.errors.minlength">Password must be 8 characters long, we need another {{password.errors.minlength.requiredLength - password.errors.minlength.actualLength}} characters </p>
    </div>
  </div>

  <div class="form-group"
       [ngClass]="{
        'has-danger': language.invalid && (language.dirty || language.touched),
        'has-success': language.valid && (language.dirty || language.touched)
      }">
    <label>Language</label>
    <select class="form-control"
            formControlName="language">
      <option value="">Please select a language</option>
      <option *ngFor="let lang of langs"
              [value]="lang">{{lang}}
      </option>
    </select>
  </div>

  <pre>{{myform.value | json}}</pre>
</form>`
})
class ModelFormComponent implements OnInit {
  langs: string[] = [
    'English',
    'French',
    'German',
  ];
  myform: FormGroup;
  firstName: FormControl;
  lastName: FormControl;
  email: FormControl;
  password: FormControl;
  language: FormControl;


  ngOnInit() {
    this.createFormControls();
    this.createForm();
  }

  createFormControls() {
    this.firstName = new FormControl('', Validators.required);
    this.lastName = new FormControl('', Validators.required);
    this.email = new FormControl('', [
      Validators.required,
      Validators.pattern("[^ @]*@[^ @]*"),
      emailDomainValidator
    ]);
    this.password = new FormControl('', [
      Validators.required,
      Validators.minLength(8)
    ]);
    this.language = new FormControl('');
  }

  createForm() {
    this.myform = new FormGroup({
      name: new FormGroup({
        firstName: this.firstName,
        lastName: this.lastName,
      }),
      email: this.email,
      password: this.password,
      language: this.language
    });
  }
}


@Component({
  selector: 'app',
  template: `<model-form></model-form>`
})
class AppComponent {
}


@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    ReactiveFormsModule],
  declarations: [
    AppComponent,
    ModelFormComponent
  ],
  bootstrap: [
    AppComponent
  ]
})
class AppModule {
}

platformBrowserDynamic().bootstrapModule(AppModule);

Template Driven Listing

Listing 2. script.ts
import {
    NgModule,
    Component,
    OnInit,
    ViewChild,
    Directive,
    Inject,
    Input,
} from '@angular/core';
import {
    NG_VALIDATORS,
    FormsModule,
    FormGroup,
    FormControl,
    ValidatorFn,
    Validators
} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';

class Signup {
  constructor(public firstName: string = '',
              public lastName: string = '',
              public email: string = '',
              public password: string = '',
              public language: string = '') {
  }
}

function emailDomainValidator(control: FormControl) {
  let email = control.value;
  if (email && email.indexOf("@") != -1) {
    let [_, domain] = email.split("@");
    if (domain !== "codecraft.tv") {
      return {
        emailDomain: {
          parsedDomain: domain
        }
      }
    }
  }
  return null;
}

@Directive({
  selector: '[emailDomain][ngModel]',
  providers: [
    {
      provide: NG_VALIDATORS,
      useValue: emailDomainValidator,
      multi: true
    }
  ]
})
class EmailDomainValidator {
}


@Component({
  selector: 'template-form',
  template: `<!--suppress ALL -->
<form novalidate
      (ngSubmit)="onSubmit()"
      #f="ngForm">

	<fieldset ngModelGroup="name">
		<div class="form-group"
		     [ngClass]="{
        'has-danger': firstName.invalid && (firstName.dirty || firstName.touched),
        'has-success': firstName.valid && (firstName.dirty || firstName.touched)
   }">
			<label>First Name</label>
			<input type="text"
			       class="form-control"
			       name="firstName"
			       [(ngModel)]="model.firstName"
			       required
			       #firstName="ngModel">
			<div class="form-control-feedback"
			     *ngIf="firstName.errors && (firstName.dirty || firstName.touched)">
				<p *ngIf="firstName.errors.required">First name is required</p>
			</div>
		</div>

		<div class="form-group"
		     [ngClass]="{
        'has-danger': lastName.invalid && (lastName.dirty || lastName.touched),
        'has-success': lastName.valid && (lastName.dirty || lastName.touched)
   }">
			<label>Last Name</label>
			<input type="text"
			       class="form-control"
			       name="lastName"
			       [(ngModel)]="model.lastName"
			       required
			       #lastName="ngModel">
			<div class="form-control-feedback"
			     *ngIf="lastName.errors && (lastName.dirty || lastName.touched)">
				<p *ngIf="lastName.errors.required">Last name is required</p>
			</div>
		</div>
	</fieldset>


	<div class="form-group"
	     [ngClass]="{
        'has-danger': email.invalid && (email.dirty || email.touched),
        'has-success': email.valid && (email.dirty || email.touched)
   }">
		<label>Email</label>
		<input type="email"
		       class="form-control"
		       name="email"
		       [(ngModel)]="model.email"
		       required
		       pattern="[^ @]*@[^ @]*"
		       emailDomain
		       #email="ngModel">
		<div class="form-control-feedback"
		     *ngIf="email.errors && (email.dirty || email.touched)">
			<p *ngIf="email.errors.required">Email is required</p>
			<p *ngIf="email.errors.pattern">Email must contain at least the @ character</p>
			<!--<p *ngIf="email.errors.emailDomain">Email must be on the codecraft.tv domain</p>-->
			<p *ngIf="email.errors.emailDomain">Email must be on the {{ email.errors.emailDomain.requiredDomain }} domain</p>
		</div>
	</div>


	<div class="form-group"
	     [ngClass]="{
        'has-danger': password.invalid && (password.dirty || password.touched),
        'has-success': password.valid && (password.dirty || password.touched)
  }">
		<label>Password</label>
		<input type="password"
		       class="form-control"
		       name="password"
		       [(ngModel)]="model.password"
		       required
		       minlength="8"
		       #password="ngModel">
		<div class="form-control-feedback"
		     *ngIf="password.errors && (password.dirty || password.touched)">
			<p *ngIf="password.errors.required">Password is required</p>
			<p *ngIf="password.errors.minlength">Password must be at least 8 characters long</p>
		</div>
	</div>

	<div class="form-group">
		<label>Language</label>
		<select class="form-control"
		        name="language"
		        [(ngModel)]="model.language">
			<option value="">Please select a language</option>
			<option *ngFor="let lang of langs"
			        [value]="lang">{{lang}}
			</option>
		</select>
	</div>

	<button type="submit"
	        class="btn btn-primary"
	        [disabled]="f.invalid">Submit
	</button>

	<pre>{{f.value | json}}</pre>
</form>
`
})
class TemplateFormComponent {

  model: Signup = new Signup();
  @ViewChild('f') form: any;

  langs: string[] = [
    'English',
    'French',
    'German',
  ];

  onSubmit() {
    if (this.form.valid) {
      console.log("Form Submitted!");
      this.form.reset();
    }
  }
}

@Component({
  selector: 'app',
  template: `<template-form></template-form>`
})
class AppComponent {
}


@NgModule({
  imports: [
    BrowserModule,
    FormsModule
  ],
  declarations: [
    AppComponent,
    TemplateFormComponent,
    EmailDomainValidator
  ],
  bootstrap: [
    AppComponent
  ]
})
class AppModule {
}

platformBrowserDynamic().bootstrapModule(AppModule);

Learn Angular 5 For FREE

I've released my 700 page Kick Starter funded Angular 5 book for FREE