Bean validation in Angular

January 14, 2019

In this article, I’d like to share another point of view on Angular Reactive Forms validations

What’s wrong with it

With Angular 2+ we have a great new option to create forms, namely the Reactive forms based on a model-driven approach. More detailed info could be found in the documentation.

Let’s have a look at several examples of reactive form creation:

Begin with the simplest variant:

private userForm: FormGroup = this.formBuilder.group({
    email: '',
    name: ''
});

not bad for a start. Here is the model, two fields, both are empty.

Let’s make it more complicated and assume that email is unchangeable, i.e. has status disabled:

private userForm: FormGroup = this.formBuilder.group({
    email: [{value: '', disabled: true}],
    name: ''
});

In the example above, an array has appeared, which holds the configuration for the email field.

Perfect, let’s expand the config and add some validation:

private userForm: FormGroup = this.formBuilder.group({
    email: [{value: '', disabled: false}, Validators.required],
    name: ''
});

Everything is ok so far, described briefly and clearly.

From my point of view, beginning from this example, the form config becomes more complicated:

private userForm: FormGroup = this.formBuilder.group({
    email: ['', Validators.compose([Validators.required, Validators.email])],
    name: ''
});

What happens here? To add some validators for one field, the additional function must be used Validators.compose(). At first thought, nothing special, there are only two validators for now.

And let’s imagine quite real user’s model:

private userForm: FormGroup = this.formBuilder.group({
    email: ['', Validators.compose([Validators.required, Validators.email])],
    name: ['', Validators.compose([Validators.required, Validators.minLength(3), Validators.maxLength(40)])],
    age: ['', Validators.compose([Validators.number, Validators.min(18), Validators.max(60)])],
    creditCards: [{
        cardNumber: ['', Validators.compose(Validators.isCreditCard, Validators.isMasterCard)],
        date: ['', Validators.compose([Validators.required, Validators.pattern(/^(0[1-9]|1[0-2])\/?([0-9]{4}|[0-9]{2})$/)])],
        cvv: ['', Validators.compose([Validators.required, Validators.pattern(/^[0-9]{3,4}$/)])]
    }],
    address: {
        addressLine1: ['', Validators.required],
        addressLine2: '',
        city: ['', Validators.required],
        region: ['', Validators.required],
        zip: ['', Validators.compose([Validators.required, Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/)])],
        country: ['', Validators.required]
    },
    deliveryDate: ['', Validators.compose([Validators.required, Validators.isDate, Validators.dateBefore(someValue), Validators.dateAfter(someValue)])]
});

It is definitely not the biggest one, but as for me, it’s not a one-and-done thing. Even if you puzzled it out, it’s hard to support it, for sure.

What alternatives do we have

As a variant, we can borrow something that already works well, namely Bean Validation. Angular uses the annotation wherever possible, which means that at least support for annotations is already there. We just have to take this same approach and transfer it to the angular scope.

What do we need for this

To start with – annotations. With the help of them, we write in our model metadata what validators for which field should be applied.

const setSyncValidator = (target: any, key: string, validator: ValidatorFn): void => {
    let metadata = Reflect.get(target, setName(key)) as FormMetadata;
    metadata = metadata || {annotated: true};
    metadata.syncValidators = metadata.syncValidators || [];
    metadata.syncValidators.push(validator);

    Reflect.set(target, setName(key), metadata);
};

const Required = (): AnnotationFunction => (target: object, key: string): void => {
    setSyncValidator(target, key, Validators.required);
};

Perfect, data have been written, but angular can’t deal with it yet. It needs the config described above. For this purpose, you can create a generator, that will be able to create config itself from metadata, which angular knows how to deal with.

/**
 * Method that expects annotated instance
 */
public generateAbstractControls(annotatedInstance: T): ControlConfig {
    /**
    * With the help of Proxy, we add the ability to get object’s metadata by accessing its property.
    */
    const controls = new Proxy(annotatedInstance, {
        get: (target: any, name: any): any => {
            return name in target ? Reflect.get(target, name) : undefined;
        }
    });

    const newControl: any = {};
    for (const prop in controls) {
        /**
        * Check whether the property is annotated since objects could have external properties that will interfere with us. 
        */
        if (controls[prop] instanceof Object && controls[prop].annotated) {
            /**
            * Check for different types of annotations, whether it's `FormGroup`, or `FormArray`, or just `FormControl`. 
            * metadata.nested - FormGroup
            * metadata.nestedArray - FromArray
            * metadata.syncValidators - compose sync validators
            * metadata.asyncValidators - compose async validators
            */
            const metadata = controls[prop];
            const controlName = getName(prop);

            let syncValidators: ValidatorFn;
            let asyncValidators: AsyncValidatorFn;
            /**
             * setup and compose validators for current prop
            */
            if (metadata.syncValidators) {
                syncValidators = Validators.compose(metadata.syncValidators);
            }

            if (metadata.asyncValidators) {
                asyncValidators = Validators.composeAsync(metadata.asyncValidators);
            }

            if (metadata.nested) {
                newControl[controlName] = new FormGroup(this.generateControlsConfig(controls[controlName], syncValidators, asyncValidators));
                continue;
            }
            
            if (metadata.nestedArray) {
                const arrayForm = controls[controlName].map((control: any) => new FormGroup((this.generateControlsConfig(control))));
                newControl[controlName] = new FormArray(arrayForm, syncValidators, asyncValidators);
                continue;
            }

            newControl[controlName] = new FormControl({
                value: controls[controlName],
                disabled: metadata.disabled
            }, syncValidators, asyncValidators);
        }
    }

    /**
    * On the output, we get the config for FormBuilder
    */
    return newControl;
}

How to use it

Let’s have a look how our models look.

class User {
    @Email()
    @Required()
    email: string;

    @MaxLength(40)
    @MinLength(3)
    @Required()
    name: string;

    @Max(60)
    @Min(18)
    @Number()
    @Required()
    age: number;

    @NestedArray()
    creditCards: CreditCard[] = [new CreditCard()];

    @Nested()
    address: Address = new Address();

    @DateAfter(new Date())
    @DateBefore(new Date())
    @IsDate()
    @Required()
    deliveryDate: string;
}

class CreditCard {
    @IsMasterCard()
    @IsCreditCard()
    @Required()
    cardNumber: string;

    @Pattern(/^(0[1-9]|1[0-2])\/?([0-9]{4}|[0-9]{2})$/)
    @Required()
    date: string;

    @Pattern(/^[0-9]{3,4}$/)
    @Required()
    cvv: string;
}

class Address {
    @Required()
    addressLine1: string;

    @EmptyControl()
    @Required()
    addressLine2: string;

    @Required()
    city: string;

    @Required()
    region: string;

    @Pattern(/^\d{5}(?:[-\s]\d{4})?$/)
    @Required()
    zip: string;

    @Required()
    country: string;
}

Here are interfaces in the form of classes, briefly described, with direct validators that could be used independently.

import {FromGroup} from '@angular/forms';
import {User} from './user';
import {BeanFormGroup} from 'ngx-bean-validation';

class Component {
  userForm: FromGroup = new BeanFormGroup(new User())
}

A small example of how to create a form group.

Pros and cons

Pros

Cons

Conclusion

Ngx-bean-validation library is already stored in the npm. Everyone who wants to participate in its development or has some thoughts on improving the code and the idea as a whole, please welcome. I hope this article was useful.

Upgrade yourself and make the world around you better, the sky is the limit!

About the author: Sergey Netyaga
Comments
Join us