Handling validation messages in Angular like a boss
Tired of writing validation messages for each control and applying conditions to show them? In this post, I will explain how to create a reusable component for printing error messages and styling them easily.
The agenda:
- Write a wrapper component that will project a form control and print the error messages
- Define an error config to show the error messages
- Show an error in the template dynamically
- Add
ngx-translate
to enable translations and augment them with error details
A little of the background. I’ve done a project where a user would start with just 20 required fields and could add many more depending on their need. The forms were fluid and extensible in width and depth.
The simplest form group started from 7 controls each of which had validation messages to show. Then the quick realization came into mind that the basic approach that is usually being shown in tutorials where you hard code each message in the template for every single field isn’t practical.
This is where the idea of having a shared error printing logic was born. Now let’s get to the code.
Defining an anchor
First, we will start with an anchor. For an anchor, we will use a directive that is put exclusively onto input fields and which we will use as a selector to register the control in our wrapper.
For now, let’s keep it simple
@Directive({
selector: 'input[myInput]'
)
export class MyInputDirective {
constructor(@Optional() @Self() public ngControl: NgControl) {}
}
Here, we define a directive, from the host element injectNgControl
which is optional and make it public so we can access it from our wrapper.
Creating a wrapper
Now it’s time to create a basic wrapper that will project input and print some messages after it. The wrapper will get a reference to the input through the @ContentChild
decorator using previously created directive as a selector.
@Component({
selector: 'my-form-field',
templateUrl: './form-field.component.html',
styleUrls: ['./form-field.component.css'],
})
export class FormFieldComponent implements OnInit {
@ContentChild(MyInputDirective, { static: true })
myDirective: MyInputDirective;
ngOnInit() {
if (!this.myDirective) {
throw new Error('MyInputDirective is required!')
}
}
}
Notice the {static: true}
in the ContentChild
arguments, that allows us to access the directive right at the component’s initialization and warn the developers that the directive is required. The template, for now, is simple<ng-content></ng-content>
.
Configuring the error messages
Now, let’s define our error messages. We will be using an object where the keys will correspond to the control error keys, and the values are the messages to print.
export const ERROR_MESSAGES = {
required: 'This field is required',
minlength: 'The entered value is too short',
maxlength: 'The entered value is too long',
}
Displaying validation error messages
For this one, we will need to add a getter to the wrapper which will return an error message if any.
get errorMessage(): string | null {
const errors = Object.entries(
this.myDirective?.ngControl?.control?.errors || {}
);
if (!errors.length) {
return null;
}
const [key, value] = errors[0];
return ERROR_MESSAGES[key];
}
And update the wrapper’s template so as to show the error message.
<ng-content></ng-content>
<div *ngIf="errorMessage">{{ errorMessage }}</div>
Now, let’s update the app.component.html
file with the following content
<my-form-field>
<input [(ngModel)]="name" myInput type="text" required minlength="4" />
</my-form-field>
Now, it’s time to check how it works
Great! At last, we can observe things getting right!
Adding translations to the equation
Nowadays, most of our apps are shipped with multiple locales. One of the most popular libraries for this is ngx-translate
. It enables us to change the translations in real-time with only one app build.
Let’s define a couple of translations, for brevity, I’ll define them in a TS file
export default {
errorMessages: {
required: 'This field is required',
minlength: 'The entered value should be longer than {{requiredLength}}',
maxlength: 'The entered value should be shorter than {{requiredLength}}',
},
};
and update our ERROR_MESSAGES config
export const ERROR_MESSAGES: Record<string, string> = {
required: 'errorMessages.required',
minlength: 'errorMessages.minlength',
maxlength: 'errorMessages.maxlength',
};
also, we would need to update our wrapper to display translated messages rather than hardcoded strings.
constructor(private translateService: TranslateService) {}
get errorMessage(): { key: string; options: any } | null {
const errors = Object.entries(
this.myDirective?.ngControl?.control?.errors || {}
);
if (!errors.length) {
return null;
}
const [key, value] = errors[0];
return {
key: ERROR_MESSAGES[key],
options: value,
};
}
<ng-content></ng-content>
<div *ngIf="errorMessage as config">
{{ config.key | translate: config.options }}
</div>
Now, that we updated returned value we use the “key” as a translation string and its “options” as an object which will be passed as translation options.
Let’s check what we have now
Yay! Finally, we can provide our users with something more specific than “too long” or “too short”!
Styling time
Here we will use the directive’s selector, this will help us encapsulate input styles.
.error-message {
color: red;
}
:host ::ng-deep {
input[myInput] {
display: block;
padding: 10px;
margin-top: 5px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 5px;
color: #4a4a4a;
&:focus {
border-color: rgba(0, 0, 0, 0.7);
outline: 0;
}
}
}
Everything is encapsulated in the form-field styles. This enables us to keep all form styles close, also we won’t be wandering across the project searching if we have dead styles, because all classes here are either applied in the form-field’s template or attached to the host by MyInputDirective (these are the rules you must apply to make your development easier and more organized).
Summarizing what we’ve done today
We have separated translations definitions from the controls they are used for, made our templates clean and DRY as we no longer need to copy-paste the same validation rules.
The error messages are not only translated but also are independent of whether we use FormsModule or ReactiveFormsModule.
Beware, that these won’t be shown if you don’t use Angular forms.
Ways to make it better even more:
- Introduce the OnPush strategy
- Add an optional input to the FormFieldComponent where we could pass an object with translations overrides
- Extend directive’s selector to accept different controls
- Adjust error message show condition to wait for input value being changed
The resulting code is available on GitHub