Validations in and out of the ControlValueAccessor
See this and many other articles at lucaspaganini.com
Chances are you've already used Angular form validators. In this article, I'll show you how they work and how to create your own, but there's already plenty of content teaching that.
What I want to do here is to take it a step further. Instead of just teaching you how to use validators from outside, I'll teach you how to use them from inside.
Angular Validators
Let's start with the basics. When you create a FormControl
, you can optionally give it an array of validators. Some validators are synchronous and others are asynchronous.
Some needed to be implemented by the angular team to comply with the native HTML specification, like [min]
, [max]
, [required]
, [email]
, so on… Those can be found in the Angular forms library.
import { Validators } from '@angular/forms';
new FormControl(5, [Validators.min(0), Validators.max(10)]);
new FormControl('test@example.com', [Validators.required, Validators.email]);
Reactive vs Template
If you declare an input element with the required
attribute while using the FormsModule
, Angular will turn that input into a ControlValueAccessor
(again, read the first article if you haven't done yet), it will create a FormControl
with the required validator and attach the FormControl
to the ControlValueAccessor
<input type="text" name="email" [(ngModel)]="someObject.email" required />
That all happens in the background and with no type safety. That's why I avoid the FormsModule
, it's too magical and untyped for my taste, I'd rather work with something more explicit, and that's where the ReactiveFormsModule
comes into play.
Instead of using the banana syntax that does all that magic for you, in the reactive forms way, you'd:
- Instantiate your
FormControl
manually; - Attach the validators manually;
- Listen to changes manually;
- And attach it to the
ControlValueAccessor
semi-manually.
Apart from that last step, all of that's done in your TypeScript file, not in the HTML template. And that gives you a lot more type safety. It's not perfect, it does treat the inner values as any
, but they're working to change that and there's also a good library to work around that issue in the meantime.
ValidatorFn
Enough theory, let's see some actual coding.
In the last article, we implemented a date input. But as mentioned at the end of the article, I want to change it so that it only accepts business days. That means:
- No weekends
- No holidays
- No nonexistent dates (like February 31)
Let's start by handling the weekends. I have a simple function that receives a Date
and returns a boolean indicating if that date is a weekend.
enum WeekDay {
Sunday = 0,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
export const isWeekend = (date: Date): boolean => {
const weekDay = date.getDay();
switch (weekDay) {
case WeekDay.Monday:
case WeekDay.Saturday:
return true;
default:
return false;
}
};
That's good, but we need a different function signature for this to work. What Angular expects from a ValidatorFn
is for it to return null
if everything's fine and an object when something's wrong.
The properties of the returned object are ids for the errors. For example, if the date is a weekend, I'll return an object with the property weekend set to true. That means the FormControl
now has an error, called "weekend"
and its value is true
. If I do FormControl.getError('weekend')
, I get true
. And if I do FormControl.valid
, I get false
, because it has an error, so it's not valid.
You could give any value to the error property. For example, you could give it "Saturday"
, and when you call FormControl.getError('weekend')
, you'll get "Saturday"
.
By the way, the validator function doesn't receive the value as a parameter, it receives the AbstractControl
that's wrapping the value. An AbstractControl
could be a FormControl
, a FormArray
, or a FormGroup
, you just have to take the value from it before doing your validation.
export const weekendValidator: ValidatorFn = (
control: AbstractControl
): null | { weekend: true } => {
const value = control.value;
if (isDate(value) === false) return null;
if (isWeekend(value)) return { weekend: true };
return null;
};
Also, don't forget that the value could be null
or something different instead of a Date
, so it's always good to handle those edge cases. For this weekend's validator function, I'll just bypass it if the value is not a date.
Ok, now that it's done, you just have to use it like you would with Validators.required
.
export class AppComponent {
public readonly dateControl = new FormControl(new Date(), [weekendValidator]);
}
AsyncValidatorFn
Now let's tackle the holiday validator.
This is a different case because we'll need to hit an external API to consult if the given date is a holiday or not. And that means it is not synchronous, so we can't possibly return null
or an object. We'll need to rely on Promise
s or Observable
s.
Now, I don't know about you, but I prefer to use Promise
s when possible. I like Observable
s and I happen to know a lot about them, but they are uncomfortable for a lot of people. I find Promise
s to be much more widely understood and overall simpler.
The same applies for fetch
versus Angular's HTTPClient
. If I'm not dealing with server-side rendering, I'll skip the HTTPClient
and go with fetch
.
So I've made a function that receives a Date
and returns a Promise
of a boolean
, indicating if that date is a holiday. To make it work, I'm using a free API that gives me a list of holidays for a given date.
I'm using their free plan, so I am limited to one request per second and only holidays from this year. But for our purposes, that'll do just fine.
export const isHoliday = async (date: Date): Promise<boolean> => {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const currentYear = new Date().getFullYear();
if (year < currentYear) {
console.warn(
`We're using a free API plan to see if the date is a holiday and in this free plan we can only check for dates in the current year`
);
return false;
}
// This is to make sure I only make one request per second
await holidayQueue.push();
const queryParams = new URLSearchParams({
api_key: environment.abstractApiKey,
country: 'US',
year: year.toString(),
month: month.toString(),
day: day.toString()
});
const url = `https://holidays.abstractapi.com/v1/?${queryParams.toString()}`;
const rawRes = await fetch(url);
const jsonRes = await rawRes.json();
return (
isArray(jsonRes) &&
isEmpty(jsonRes) === false &&
// They return multiple holidays and I only care if it's a national one
jsonRes.some((holiday) => holiday.type === 'National')
);
};
Just like our previous case, this signature won't do. What Angular expects from an AsyncValidatorFn
is for it to receive an AbstractControl
and return null
or an object wrapped in a Promise
or an Observable
.
export const holidayValidator: AsyncValidatorFn = async (
control: AbstractControl
): Promise<null | { holiday: true }> => {
const value = control.value;
if (isDate(value) === false) return null;
if (await isHoliday(value)) return { holiday: true };
return null;
};
Again, don't forget to handle edge cases if the value is not a Date
.
And now we can use it in our FormControl
. Note that the AsyncValidatorFn
s are the third parameter to a FormControl
, not the second.
export class AppComponent {
public readonly dateControl = new FormControl(
new Date(),
[weekendValidator],
[holidayValidator]
);
}
Validator
So far so good, now there's only one check left: see if the date exists.
I have a function here that receives the day, month, and year and returns a boolean indicating if that date exists. It's a rather simple function, I create a Date
object from the given values and check if the year, month, and day of the newly created date are the same as the ones used to construct it.
export const dateExists = (
year: number,
month: number,
day: number
): boolean => {
const date = new Date(year, month - 1, day);
return (
date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day
);
};
You might think that's so obvious that it's almost useless. To you, I say: you don't know the Date
constructor, it is tricky…
See, you might think that instantiating a Date
with February 31 would throw an error. But it does not., it gives you March 03 (please ignore leap years for the sake of this example).
new Date(2021, 1, 31);
//=> March 03, 2021
Because of that, we can't take a Date
object and tell if it's an existing date or not because we can't see what day, month, and year were used to instantiate it. But if you have that information, you can try to create a date and see if the day, month, and year of the created date are what you were expecting.
Unfortunately, our date input doesn't give us that information, it only handles back the already instantiated Date
object. We could do a bunch of hacks here, like creating a public method in the date input component that gives us those properties, and then we would grab the component instance and do our check.
That seems wrong though, we would be exposing internal details of our component and that's never a good idea, it should be a black box. There must be a better solution, and there is one. We can validate from inside the component.
There's an interface called Validator
exported in the Angular forms library, and it's very similar to our ControlValueAccessor
pattern. You implement the interface in your component and provide the component itself in a specific multi-token. NG_VALIDATORS
, in this case.
To comply with the Validator
interface, you just need a single method called validate()
. This method is a ValidatorFn
. It receives an AbstractControl
and returns null
or an object with the occurred errors.
But since we're inside the component, we don't really need the AbstractControl
, we can grab the value ourselves.
public validate(): { invalid: true } | null {
if (
this.dayControl.invalid ||
this.monthControl.invalid ||
this.yearControl.invalid
)
return { invalid: true };
const day = this.dayControl.value;
const month = this.monthControl.value;
const year = this.yearControl.value;
if (dateExists(year, month, day)) return { invalid: true };
const date = new Date(year, month - 1, day);
if (isWeekend(date)) return { weekend: true };
if (await isHoliday(date)) return { holiday: true };
return null;
}
This works just like the ValidatorFn
s we were passing to the FormControl
, but it works from inside. And it has two benefits:
- It would be a nightmare to implement this check from outside the component;
- We don't need to declare it every time we create a
FormControl
, it'll be present in the component by default.
That second benefit really appeals to me, I think it makes total sense for our date component to be responsible for its own validation. If we wanted to customize it, we could create @Input
s, like [holiday]="true"
means we're ok with the date being a holiday and that this check should be skipped.
I won't implement those customizations because they're outside the scope of this article, but now you know how I would do it.
As I've said, I think it makes total sense for our date component to be responsible for its own validation. So let's bring our other synchronous validator inside too.
public validate(): {
invalid?: true;
weekend?: true;
} | null {
if (
this.dayControl.invalid ||
this.monthControl.invalid ||
this.yearControl.invalid
)
return { invalid: true };
const day = this.dayControl.value;
const month = this.monthControl.value;
const year = this.yearControl.value;
if (dateExists(year, month, day)) return { invalid: true };
const date = new Date(year, month - 1, day);
if (isWeekend(date)) return { weekend: true };
return null;
}
AsyncValidator
The last thing missing is to also bring our asynchronous validator inside. And that'll be easy, we just need a few adjustments.
Instead of implementing the Validator
interface, we'll implement the AsyncValidator
interface. And instead of providing our component in the NG_VALIDATORS
token, we'll provide it in the NG_ASYNC_VALIDATORS
token.
Now our validate()
method expects to be an AsyncValidatorFn
, so we'll need to wrap its return value in a Promise
.
public async validate(): Promise<{
invalid?: true;
holiday?: true;
weekend?: true;
} | null> {
if (
this.dayControl.invalid ||
this.monthControl.invalid ||
this.yearControl.invalid
)
return { invalid: true };
const day = this.dayControl.value;
const month = this.monthControl.value;
const year = this.yearControl.value;
if (dateExists(year, month, day)) return { invalid: true };
const date = new Date(year, month - 1, day);
if (isWeekend(date)) return { weekend: true };
if (await isHoliday(date)) return { holiday: true };
return null;
}
Now that all validators are implemented inside the component, we can remove them from outside.
export class AppComponent {
public readonly dateControl = new FormControl(new Date());
}
Conclusion
I'll leave a link for the repository in the references below.
Have a great day, and I'll see you soon!
References
- Repository GitHub
- Introduction to ControlValueAccessors Lucas Paganini Channel
- Pull request to make Angular forms strictly typed GitHub
- Library for typed forms in the meantime npm
- Article explaining how the typed forms library was created Indepth
- Angular form validation from outside Angular docs
- Angular validation from inside Angular docs
- Angular async validation from inside Angular docs
Top comments (0)