Custom control validation in Angular
Validation is an integral part of a web application. Making sure that a first validation pass is done on the client side can (to some extent) help you cleanse your data and show useful messages to your users when the input data is invalid. Angular comes with a pre-determined set of validators, and you can find find some validators libraries on GitHub, but sometimes this is not enough as you need custom rules for validating your data. Since form validation can occur through reactive forms or template driven forms, let’s go over each of them to see how we can add a custom validator in each case.
Custom validator through reactive forms
Validation can occur on a formControl or on a formGroup. As you know, you can create a formGroup through the FormBuilder class that you inject into your component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Component } from '@angular/core'; import { FormGroup, FormBuilder, Validators } from '@angular/forms'; @Component({ selector: 'my-component', ... }) export class MyComponent { private myForm: FormGroup; constructor(private formBuilder: FormBuilder) { this.initializeMembers(); } private initializeMembers(): void { this.myForm = this.formBuilder.group({ firstName: ['',[Validators.required]], lastName: ['',[Validators.required]], dob: ['',[Validators.required]] }); } } |
The same can be done without the FormBuilder class however.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Component } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; @Component({ selector: 'my-component', ... }) export class AppComponent { private myForm: FormGroup; constructor() { this.initializeMembers(); } private initializeMembers(): void { this.myForm = new FormGroup({ firstName: new FormControl('',[Validators.required]), lastName: new FormControl('',[Validators.required]), dob: new FormControl('',[Validators.required]) }); } } |
A validator needs to return (or be) a function that takes an AbstractControl as input and returns an object where the key is a string and value is anything(a.k.a object):
(control: AbstractControl): {[key: string]: any}
The key of the return object is usually the validatorName, but can be what you desire. The key is the property that will reside in the errors object of the formControl/formGroup control that can be accessed through the hasError() method. Angular checks if the control/group is in error if a key exists in its errors dictionary and if the value of that key is evaluates to true.
When building a validator, I like to check if the required validator already exists and just return that the validator is valid. This is just for comfort and eases out an extra processing for nothing. When the required validator is present, it usually mean that the user has yet to input data, so we know that our validator won’t be valid anyway.
For the sake of the example, we will create a custom validator that checks if the input is a valid ISO 8601 (yyyy-mm-dd) date and if that date is the end of the month.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'; export function dateIsEndOfMonth(control: AbstractControl): {[key: string]: any} { if (isPresent(Validators.required(control))) return null; const isISOdate = /\d{4}-\d{2}-\d{2}/.test(control.value); if (!isISOdate) return {'dateIsEndOfMonth' : true}; const isValidDate = isDate(control.value); if (!isValidDate) return {'dateIsEndOfMonth' : true}; const date = new Date(control.value.replace(/-/g, '\/')); return !isEndOfMonth(date) ? {'dateIsEndOfMonth' : true} : null; } function isPresent(obj: any): boolean { return obj !== undefined && obj !== null; } function isDate(obj: any): boolean { return !/Invalid|NaN/.test(new Date(obj).toString()); } function isEndOfMonth(date: Date) { const d = new Date(date.getTime()); d.setDate(d.getDate() + 1); return d.getDate() === 1; } |
For simplicity reasons, I added all the testing functions in the file, but isEndOfMonth, for instance, could have been an extension of the Date object (Date.prototype)
We can now add the validator to our control/group and change the dob control from new FormControl('',[Validators.required])
to new FormControl('',[Validators.required,dateIsEndOfMonth])
.
Now assume we want to pass a pre-defined parameter to our validator. We can do so by creating a factory method: a method wrapper that will return the validator function based on some input. Here is an example where one would want to create a validator function to accept a parameter. This can be useful when you would want for instance have a custom regular expression to test the data input.
This example below checks that the input is an odd number, but allows for certain even numbers to be entered.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
export function isOddWithEvenAllowed(allowedEvenNumbers: number[]): ValidatorFn { return (control: AbstractControl): {[key: string]: any} => { if (isPresent(Validators.required(control))) return null; const val = +control.value; if (isNaN(val)) return {'isOddWithEvenAllowed' : true}; for (const i = 0; i < allowedEvenNumbers.length; i++) { if (allowedEvenNumbers[i] % 2 !== 0) throw new Error(allowedEvenNumbers[i] + " is not even!"); } return allowedEvenNumbers.indexOf(val) !== -1 || val % 2 !== 0 ? null : {'isOddWithEvenAllowed' : true}; }; } |
Custom validator through template driven forms
In template driven forms, we don’t have access to the FormControl directly. As such we have to wrap our validator function (above) in a directive.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { Directive, Input } from '@angular/core'; import { NG_VALIDATORS, Validator, AbstractControl } from '@angular/forms'; import {dateIsEndOfMonth,isOddWithEvenAllowed} from './customValidators'; @Directive({ selector: 'input[type=text][dateIsEndOfMonth][formControlName],input[type=text][dateIsEndOfMonth][formControl],input[type=text][dateIsEndOfMonth][ngModel]', providers: [{provide: NG_VALIDATORS, useExisting: DateIsEndOfMonthDirective, multi: true}] }) export class DateIsEndOfMonthDirective implements Validator { validate(control: AbstractControl): {[key: string]: any} { return dateIsEndOfMonth(control); } } @Directive({ selector: 'input[type=text][isOddWithEvenAllowed][formControlName],input[type=text][isOddWithEvenAllowed][formControl],input[type=text][isOddWithEvenAllowed][ngModel]', providers: [{provide: NG_VALIDATORS, useExisting: IsOddWithEvenAllowedDirective, multi: true}] }) export class IsOddWithEvenAllowedDirective implements Validator { @Input() isOddWithEvenAllowed: string; validate(control: AbstractControl): {[key: string]: any} { return isOddWithEvenAllowed(this.isOddWithEvenAllowed.split(",").map(Number))(control); } } |
The class implements the interface Validator from @angular/forms. In the above implementation, the validator is valid on inputs of type text with the following characteristics: they are form controls or they implement the ngModel directive. This is obviously a basic example. If you are curious, you can look at how some validators that angular provide are built here.
Note that the @Input variable is of type string as it’s parsed directly from the template. In my example for the isOddWithEvenAllowed directive, my parameter to my directive was “18,24,36” and not “[18,24,36]” and i’m casting the former to an array of numbers and passing the result to my function. This is because the parameters of the directive is a string.
You can find a working example of the implementation of the 2 types above with 2 plunkrs I made: reactive forms and template driven.
On the way to validation!
You should be now able to create custom validators for your validations. As always you can always refer to Angular’s documentation on the subject for more guidance. Happy validation!