DEV Community

Lucas Paganini
Lucas Paganini

Posted on

Advanced Approaches to Angular Form Validations

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]);
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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:

  1. Instantiate your FormControl manually;
  2. Attach the validators manually;
  3. Listen to changes manually;
  4. 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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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 Promises or Observables.

Now, I don't know about you, but I prefer to use Promises when possible. I like Observables and I happen to know a lot about them, but they are uncomfortable for a lot of people. I find Promises 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')
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 AsyncValidatorFns are the third parameter to a FormControl, not the second.

export class AppComponent {
  public readonly dateControl = new FormControl(
    new Date(),
    [weekendValidator],
    [holidayValidator]
  );
}
Enter fullscreen mode Exit fullscreen mode

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 Dateobject 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
  );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This works just like the ValidatorFns we were passing to the FormControl, but it works from inside. And it has two benefits:

  1. It would be a nightmare to implement this check from outside the component;
  2. 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 @Inputs, 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I'll leave a link for the repository in the references below.

Have a great day, and I'll see you soon!

References

  1. Repository GitHub
  2. Introduction to ControlValueAccessors Lucas Paganini Channel
  3. Pull request to make Angular forms strictly typed GitHub
  4. Library for typed forms in the meantime npm
  5. Article explaining how the typed forms library was created Indepth
  6. Angular form validation from outside Angular docs
  7. Angular validation from inside Angular docs
  8. Angular async validation from inside Angular docs

Top comments (0)