Improving error handling in Angular Forms with custom Validators
When building Reactive Forms with Angular, one important aspect is the validation of input and user feedback about it.
For example, if the user tries to enter an obviously wrong email address, we expect to see an error:
Example from the Angular Material docs
Now when working with Angular Material, like in the example above, we can use <mat-error>
to display any kind of error we want to show to the user.
<mat-form-field>
<mat-label>Enter your email</mat-label>
<input matInput placeholder="test@example.com" [formControl]="emailField"/>
@if (emailField.hasError('email')) {
<mat-error>Not a valid email</mat-error>
}
</mat-form-field>
Writing custom validators
Angular provides some built-in validators like required
, minLength
or the email
Validator we saw above (full list).
But sometimes it is necessary to do some custom validation, for example making sure certain values don't appear, or some constraints which can not be handled by the default Validators.
In this case, we can just write a custom Validator: All we have to is to provide a function which implements the ValidatorFn
interface.
Let's write a ValidatorFn to make sure emails end with our company domain:
export const companyDomain: ValidatorFn
= (control: AbstractControl): ValidationErrors | null => {
if (control.value.endsWith('@company.com')) {
return null;
}
return {companyDomain: 'Email must end with company.com!'}
}
We first check if the value of the FormControl ends with our company domain, and return null
if everything is okay. If not, we return an error object with the error messages as value: Email must end with company.com!
.
The cool thing is that ValidationErrors
has the type [key: string]: any;
, meaning that we can provide not only a boolean
(like the built-in Validators), but as you maybe noticed we can directly provide the error string. We'll make use of that later!
In the same way, we can also create a function which returns a ValidatorFn, a factory for Validator-functions so to say.
export const reservedName = (reservedNames: string[])
=> (control: AbstractControl): ValidationErrors | null => {
if (reservedNames.includes(control.value)) {
return {reservedName: 'Reserved names (like admin) can not be used'};
}
return null;
}
These can be handy because they can be customized directly from the component, for example like this:
public nameControl = new FormControl('', [reservedName(['admin', 'root'])]);
Dynamically showing errors using a custom Directive
Now for the example with the username, we actually might have plenty of Validators, and our FormField could get a bit bloated with handling all those possible error cases:
<mat-form-field appearance="outline">
<mat-label>Choose new username</mat-label>
<input type="text" matInput [formControl]="name1">
@if (name1.hasError('required')) {
<mat-error>Field is required</mat-error>
} @else if (name1.hasError('minLength')) {
<mat-error>Length has to be at least 3 characters</mat-error>
} @else if (name1.hasError('maxLength')) {
<mat-error>Length can not be more than 15 characters</mat-error>
} @else if (name1.hasError('reservedName')) {
<mat-error>Reserved names (like admin) can not be used</mat-error>
}
</mat-form-field>
So the first idea would be to wrap that up in a separate component, which checks the state of the FormControl and renders any error, if found. The problem with that is that <mat-error>
must be a direct child of the <mat-form-field>
, or else the error will not be rendered properly.
If we have a custom component, it would look like this:
<mat-form-field>
<app-form-error>
<mat-error>My generic error</mat-error>
</app-form-error>
</mat-form-field>
A component will always have it's custom tag, so we can't go that way.
Directives to the rescue
But there's another thing we can use: Directives!
The idea would be to add a custom directive to our <mat-error>
which gets a reference to the FormControl and then renders an error message inside of the <mat-error>
template. Like this:
<mat-error *formError="nameControl; let message">{{ message }}</mat-error>
Let's create the Directive which takes the FormControl as Input:
@Directive({selector: '[formError]'})
export class FormErrorDirective implements OnInit {
@Input('formError') control?: AbstractControl;
}
Now we can let the Directive do the following:
- Subscribe to
valueChanges
of the FormControl and react to changes - Get the first error and the value thereof
- Render the error message into the ViewContainer (the template of the outside
<mat-error>
)
public ngOnInit(): void {
this.control?.valueChanges.subscribe(() => {
const firstError = Object.values(this.control.errors)[0];
this.viewContainer.createEmbeddedView(this.templateRef, {$implicit: firstError});
})
}
You can see the whole Directive here: source code.
Live demo
Here you can see and try it in action:
You can find the source code in Github: angular-custom-validator-example
Thanks for reading and happy validating!