Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.
A frequent criticism leveled at building custom validators for Angular template-driven forms is the amount of setup required before a validator becomes usable.
While there's some truth to that, the pattern also enforces a healthy separation of responsibilities—it introduces a dedicated layer for validating the model. For intricate models, I deliberately let the validator act as an independent layer that houses the business rules.
Where the complaint is warranted is in the number of files you need to touch (and review) to construct and register the directive. Additionally, the small but easy-to-forget step of adding the validator directive to an Angular module often trips people up, leading to unnecessary frustration.
However, it doesn't have to be this way. We can build a single validator to cover all the cases.
Let's name it
ValidatorDirective, and its only job is to take a callback that produces the validation errors.import { Directive, Input } from '@angular/core'; import { AbstractControl, ValidationErrors, NG_VALIDATORS, Validator } from '@angular/forms'; @Directive({ selector: '[validator]', providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }] }) export class ValidatorDirective implements Validator { @Input() validator: (control: AbstractControl) => ValidationErrors | null; validate(control: AbstractControl): ValidationErrors | null { return this.validator(control); } }With
ValidatorDirectivein place, we can now set up inline validators while defining the form. For example, to validate a singlengModel, the validator is created within the component. ThescoreValidatorfunction receives the control and returns the validation errors, exactly like thevalidatemethod from theValidatorinterface.@Component() export class Component { scoreValidator = (control: AbstractControl): ValidationErrors | null => { if (control.value > 10) { return { maxScore: 10 }; } if (control.value < 0) { return { minScore: 0 }; } return null; }; }In the template, the inline validator is used by binding the
validatorattribute (the directive's selector) to the validate method (scoreValidator).<input type="number" name="score" ngModel [validator]="validateScore" />Rather than authoring every validator by hand, you can just as easily call the built-in Angular validators or invoke your custom ones.
@Component() export class Component { scoreValidator = (control: AbstractControl): ValidationErrors | null => { // invoke multiple validators with `compose` // return Validators.compose([Validators.min(0), Validators.max(10)])(control); // it's also possible to short-circuit the validation return Validators.min(0)(control) || Validators.max(10)(control); }; }For simple one-off checks, this approach is fast and straightforward.
When the validation spans a form group or the whole form, it can quickly get involved. That's why it's good practice to move those rules out of the component into a dedicated method or class. Doing so keeps the component lean, simplifies the logic, and makes the validation far easier to test.
The same validator directive works for a
ngModelGroupwithout any modifications.<div ngModelGroup="person" [validator]="personValidator"> <!-- imagine multiple form fields here --> </div>@Component() export class Component { personValidator = (control: AbstractControl): ValidationErrors | null => { return validatePerson(control.value); }; }You may notice that I'm using arrow functions when writing these validators. This is intentional—it binds the method to the component instance rather than the directive instance, which means I can access other properties on the component class from within the validator.
Conclusion
Some of the boilerplate can be avoided by introducing one generic validator directive that simply takes a callback for validating a form model. It enables quick inline validators inside components, which works beautifully for basic checks—but for more complex cases, I still prefer to push the validation logic into its own layer.
When the validation rules live on their own, separate from the directive or component, the business logic stays decoupled from Angular-specific concerns.
The trade-off with this generic directive is that you lose the ability to revalidate the validator.
Demo
Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.
