Angular cross field validation with template forms

When we are building a forms based application we often need to validate form fields in relation to other fields. If we have a simple form with two dates, start date and end date that looks like the following

<form>
<label>Start date:
<input type="date" name="startDate" [(ngModel)]="startDate" required>
</label>
<br>
<label>End date:
<input #endDateInput="ngModel" type="date" name="endDate" [(ngModel)]="endDate" required>
</label>
</form>

Our form will not be valid until both fields have been filled in as they have been marked as required. But what if we want to make the form invalid if the end date is not after the the start date?

Creating our own custom validation directives is quite easy, we create a directive that implements an Angular forms Validator interface and provides the NG_VALIDATORS injector token with itself.

Here is the implementation of the after date directive that takes an input afterDate sets the form field to be invalid if the value of the form field is a date earlier than the provied afterDate input.

import { Directive, Input } from "@angular/core";
import { Validator, AbstractControl, NG_VALIDATORS } from "@angular/forms";
@Directive({
selector: "[afterDate]",
providers: [
{ provide: NG_VALIDATORS, useExisting: AfterDateDirective, multi: true }
]
})
export class AfterDateDirective implements Validator {
@Input()
afterDate: Date;
validate(c: AbstractControl): { [key: string]: any } {
if (c.value && this.afterDate && c.value < this.afterDate) {
return {
afterDate: true
};
}
return null;
}
}

We can now use this directive in our form.

<form>
<label>Start date:
<input type="date" name="startDate" [(ngModel)]="startDate" required>
</label>
<br>
<label>End date:
<input #endDateInput="ngModel" type="date" name="endDate" [(ngModel)]="endDate" required [afterDate]="startDate">
</label>
<span class="error" *ngIf="endDateInput.errors?.afterDate">Must be after start date</span>
</form>

Now we can see that if we set an end date that is before the start date we get an error shown.

This all seems to be working just as we expect unless we realise that we got the start date wrong and need to edit it, so we update the start date to a date that is before the end date.

What is going on? The start date is now before the end date so why is the form still in an error state?

The validation directive is only watching for when the end date is updated, it is not watching for when the start date input property is updated. We can extend the after date directive to also implement the OnChanges Angular life cycle hook to watch for changes to the afterDate.

Make the directive implement OnChanges, add a void onChanges placeholder method, add a registerOnValidatorChange so Angular can wire up the onChanges method for you then implement the ngOnChanges method to call the onChange method if afterDate is in the changes object.

import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core";
import { Validator, AbstractControl, NG_VALIDATORS } from "@angular/forms";
@Directive({
selector: "[afterDate]",
providers: [
{ provide: NG_VALIDATORS, useExisting: AfterDateDirective, multi: true }
]
})
export class AfterDateDirective implements Validator, OnChanges {
@Input()
afterDate: Date;
validate(c: AbstractControl): { [key: string]: any } {
if (c.value && this.afterDate && c.value < this.afterDate) {
return {
afterDate: true
};
}
return null;
}
onChange: () => void; registerOnValidatorChange(fn: () => void): void {
this.onChange = fn;
}
ngOnChanges(changes: SimpleChanges): void {
if ('afterDate' in changes && this.onChange) {
this.onChange();
}
}
}

Now if the value of the start date input property changes onChanges will be called and the validation directive will be retriggered.

Yay, we now have cross field validation working for when both the form field is updated and the other field the validation relies on is updated.

This is great if you are only creating a few validation directives but if we are creating a lot of validation directives to cover all the validation requirements of a reasonably large application we are repeating a lot of boilerplate OnChanges code. So let’s refactor this into a reusable base class.

Create a base directive called ValidatorBaseDirective that implements OnChanges as we did in our directive. Have a constructor that takes a comma separated list of input property names to watch. Have the ngOnChanges method call onChanges if any of the watched inputs are in the changes object.

import { SimpleChanges, OnChanges, Directive, Inject } from '@angular/core';@Directive({ selector: 'validatorBase' })
export class ValidatorBaseDirective implements OnChanges {
private inputs: string[];
onChange: () => void; constructor(@Inject([]) ...inputs: string[]) {
this.inputs = inputs;
}
registerOnValidatorChange(fn: () => void): void {
this.onChange = fn;
}
ngOnChanges(changes: SimpleChanges): void {
if (this.inputs.some((input) => input in changes) && this.onChange) {
this.onChange();
}
}
}

Now can simplify our validation directive by only having to extend the ValidatorBaseDirective and pass in a comma separated list of which properties to watch to the super method in our constructor, just afterDate in this case.

import { Directive, Input, SimpleChanges } from "@angular/core";
import { Validator, AbstractControl, NG_VALIDATORS } from "@angular/forms";
import { ValidatorBaseDirective } from './validator-base.directive';@Directive({
selector: "[afterDate]",
providers: [
{ provide: NG_VALIDATORS, useExisting: AfterDateDirective, multi: true }
]
})
export class AfterDateDirective extends ValidatorBaseDirective implements Validator {
@Input()
afterDate: Date;
constructor() {
super('afterDate');
}
validate(c: AbstractControl): { [key: string]: any } {
if (c.value && this.afterDate && c.value < this.afterDate) {
return {
afterDate: true
};
}
return null;
}
}

Now we can create a library of validation directives for our app with less boilerplate only having to add the inputs, call the super method to tell the base class which inputs to watch for changes and implent the validate method.

A full working example of this is available at https://stackblitz.com/edit/angular-ivy-m1cghy

Happy validating.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store