Simplifying form error validations in Angular.

jstnjs
4 min readMay 29, 2023

--

This solution is heavenly inspired by Netanel Basel with “Make your Angular forms error messages magically appear”. Definitely recommend to check out this article aswell.

When it comes to handling error validations in Angular forms, the Angular documentation provides an example that involves manually checking each error condition and rendering the corresponding error message using *ngIf directives. While this approach works, it can quickly become cumbersome and repetitive. In this article, we’ll explore a more streamlined and reusable solution.

The Angular documentation offers the following example, which showcases a single input. Now, imagine a scenario where multiple forms and inputs are utilized.

<input type="text" id="name" class="form-control"
formControlName="name" required>

<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert alert-danger">

<div *ngIf="name.errors?.['required']">
Name is required.
</div>
<div *ngIf="name.errors?.['minlength']">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors?.['forbiddenName']">
Name cannot be Bob.
</div>
</div>

Consider the possibility of simplifying this by adopting the approach outlined below.

<input type="text" id="name" class="form-control"
formControlName="name" required>
<control-error controlName="name" />

By leveraging Angular directives and a custom ControlErrorComponent, we can simplify the error validation process and remove repetitive code.

The ControlErrorComponent encapsulates the error validation logic. This component receives the form control name as an input and utilizes the FormGroupDirective to access the corresponding form control. Let’s start with creating a basicControlErrorComponent.

@Component({
standalone: true,
selector: 'control-error',
imports: [AsyncPipe],
template: '<div class="alert alert-danger">{{ message$ | async }}</div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ControlErrorComponent implements OnInit {
private formGroupDirective = inject(FormGroupDirective);
message$ = new BehaviorSubject<string>('');

@Input() controlName!: string;

ngOnInit(): void {
if (this.formGroupDirective) {
// Access the corresponding form control
const control = this.formGroupDirective.control.get(this.controlName);

if (control) {
this.setError('Field required');
}
}
}

setError(text: string) {
this.message$.next(text);
}
}

In this example, the error text is shown right away, lacks reactivity and dynamic behavior. The error is hardcoded, which limits its flexibility. To improve this, we can utilize an error configuration object that maps error keys to specific error messages. By providing a central configuration, we decouple the error handling logic from the template code and allow for easy customization and localization of error messages.

const defaultErrors: {
[key: string]: any;
} = {
required: () => `This field is required`,
minlength: ({ requiredLength, actualLength }: any) => `Name must be at least ${requiredLength} characters long.`,
forbiddenName: () => 'Name cannot be Bob.',
};

export const FORM_ERRORS = new InjectionToken('FORM_ERRORS', {
providedIn: 'root',
factory: () => defaultErrors,
});

Let’s proceed with updating the component to leverage the centralized error configuration.

export class ControlErrorComponent implements OnInit, OnDestroy {
private subscription = new Subscription();
private formGroupDirective = inject(FormGroupDirective);
errors = inject(FORM_ERRORS);
message$ = new BehaviorSubject<string>('');

@Input() controlName!: string;

ngOnInit(): void {
if (this.formGroupDirective) {
const control = this.formGroupDirective.control.get(this.controlName);

if (control) {
this.subscription = merge(control.valueChanges)
.subscribe(() => {
const controlErrors = control.errors;

if (controlErrors) {
const firstKey = Object.keys(controlErrors)[0];
const getError = this.errors[firstKey];
// Get message from the configuration
const text = getError(controlErrors[firstKey]);


// Set the error based on the configuration
this.setError(text);
} else {
this.setError('');
}
});
}
}
}

...
}

You may notice that the error messages are only triggered when modifying the text within the input field, or when a required field is added and afterwards removed.

Form with First name showing the error “This field is required”. Field Last name is showing the error “Expected 4 but got 2”.

The absence of error messages upon clicking the “Sign In” button is because of the current implementation, which only listens to changes in the input values. To address this, we can enhance the error handling by utilizing the FormGroupDirective to capture the ngSubmit event. The error messages will now also be triggered on a button click.

this.subscription = merge(control.valueChanges, this.formGroupDirective.ngSubmit)

Additionally, to provide flexibility in modifying the default required error message, we can introduce a customErrors property. This property can be utilized as follows:

<control-error controlName="name" [customErrors]="{ required: 'This could be a custom required error'}" />

Add the input element and update the text variable to make use of the of custom errors aswell.

@Input() customErrors?: ValidationErrors;

ngOnInit..
const text = this.customErrors?.[firstKey] || getError(controlErrors[firstKey]);
Form with first name showing the error “This field is required”. Last name showing the error “This could be a custom required error”.

Voilà! You now have your very own custom form validation error component. This component is designed to work seamlessly within nested components and offers great flexibility. You can place this component directly below or above the input field, or wherever you want. Below is a complete code example for your reference:

@Component({
standalone: true,
selector: 'control-error',
imports: [AsyncPipe],
template: '<div class="alert alert-danger">{{ message$ | async }}</div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ControlErrorComponent implements OnInit, OnDestroy {
private subscription = new Subscription();
private formGroupDirective = inject(FormGroupDirective);
errors = inject(FORM_ERRORS);
message$ = new BehaviorSubject<string>('');

@Input() controlName!: string;
@Input() customErrors?: ValidationErrors;

ngOnInit(): void {
if (this.formGroupDirective) {
const control = this.formGroupDirective.control.get(this.controlName);

if (control) {
this.subscription = merge(control.valueChanges, this.formGroupDirective.ngSubmit)
.pipe(distinctUntilChanged())
.subscribe(() => {
const controlErrors = control.errors;

if (controlErrors) {
const firstKey = Object.keys(controlErrors)[0];
const getError = this.errors[firstKey];
const text = this.customErrors?.[firstKey] || getError(controlErrors[firstKey]);

this.setError(text);
} else {
this.setError('');
}
});
} else {
const message = this.controlName
? `Control "${this.controlName}" not found in the form group.`
: `Input controlName is required`;
console.error(message);
}
} else {
console.error(`ErrorComponent must be used within a FormGroupDirective.`);
}
}

setError(text: string) {
this.message$.next(text);
}

ngOnDestroy(): void {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}

Thank you for reading!

--

--