Get errors from NestJS and inject to Angular forms
In every new app, we need to know how we should handle errors from the backend.
After creating a lot of frontends and backends I decided to share my experience with forms and validation errors.
Probably if you are reading this article you are also thinking about how to handle errors from the backend quickly and easily.
Backend
In the NestJS project, we need to know how to use Validation.
After this implementation, we have example responses with errors:
{
"validation": [
{
"target": {
...
},
"value": null,
"property": "email",
"children": [],
"constraints": {
"isDefined": "name should not be null or undefined",
"minLength": "name must be longer than or equal to 2 characters",
"isString": "name must be a string"
}
}
]
}
We don't need change code in backend :)
Frontend
Without explaining the basics too much, let's start by creating a control component.
In this component, we have access to FormGroup and exact control by providing [controlName]
Errors presentation
import { Component, Inject, InjectionToken, Input, Optional, SkipSelf } from '@angular/core';
import { ControlContainer, FormControl, FormGroupDirective } from '@angular/forms';
export const GLOBAL_CUSTOM_ERRORS = new InjectionToken('GLOBAL_CUSTOM_ERRORS');
export const CUSTOM_ERRORS = new InjectionToken('CUSTOM_ERRORS');
@Component({
selector: 'app-form-control-errors',
template: `
<ng-container *ngIf="isInvalidControl">
<mat-error *ngFor="let error of errors">{{ error }}</mat-error>
</ng-container>
`,
styles: [``],
viewProviders: [
{
provide: ControlContainer,
useFactory: (controlContainer: ControlContainer) => controlContainer,
deps: [[new SkipSelf(), ControlContainer]],
},
],
})
export class FormErrorsComponent {
@Input() controlName: string;
dictionaryErrors: { [p: string]: string };
isInvalidControl(): boolean {
const control = this.formControl;
return control.invalid && (control.touched || this.formGroupDirective?.submitted);
}
get formControl(): FormControl {
return this.controlContainer.control.get(this.controlName) as FormControl;
}
get errors() {
return Object.entries(this.formControl?.errors || {}).map(([key, error]) => {
return this.dictionaryErrors[key] || error;
});
}
constructor(
@Optional() @Inject(GLOBAL_CUSTOM_ERRORS) public readonly globalCustomErrors: Record<string, string>,
@Optional() @Inject(CUSTOM_ERRORS) public readonly customErrors: Record<string, string>,
private readonly controlContainer: ControlContainer,
private readonly formGroupDirective: FormGroupDirective
) {
this.dictionaryErrors = Object.fromEntries([
...Object.entries(globalCustomErrors || {}),
...Object.entries(customErrors || {}),
]);
}
}
I use two inject tokens: global and local.
Global - in root usage with default messages:
// module provider
{
provide: GLOBAL_CUSTOM_ERRORS,
useValue: {
isEmail: 'Enter a valid email address',
},
}
Local - in a component if we need this
{
provide: CUSTOM_ERRORS,
useValue: {
customError: 'Some custom error'
}
}
Types
type ValidationConstraints = Record<string, string>;
interface ValidationError {
property: string;
constraints: ValidationConstraints;
children: ValidationError[];
}
Create functions for transform response
The best place is our frontend for this operation, less CPU usage in backend :)
export function createErrorObject(errors: ValidationError[], currentPath = ''): Record<string, ValidationConstraints> {
const errorObject: Record<string, ValidationConstraints> = {};
errors.forEach(error => {
const fullPath = currentPath ? `${currentPath}.${error.property}` : error.property;
if (error.constraints) {
errorObject[fullPath] = error.constraints;
}
if (error.children && error.children.length > 0) {
const childErrors = createErrorObject(error.children, fullPath);
Object.assign(errorObject, childErrors);
}
});
return errorObject;
}
Inject errors to form controls
In this function, we add errors from the backend to controls.
export function addErrorsToControls(
form: FormGroup,
errors: Record<string, ValidationConstraints>,
defs: Record<string, string>
): void {
Object.keys(errors).forEach(errorPath => {
const controlPath = defs[errorPath];
if (controlPath) {
let control: AbstractControl | null = form;
const pathParts = controlPath.split('.');
pathParts.forEach(part => {
control = control?.get(part);
});
const errorConstraints = errors[errorPath];
if (control) {
Object.keys(errorConstraints).forEach(constraintKey => {
control?.setErrors({ ...control?.errors, [constraintKey]: errorConstraints[constraintKey] });
});
} else {
form?.setErrors({ ...form?.errors, [errorPath]: errorConstraints });
}
}
});
}
If some control is not found, then an error has been added to the form.
Example usage:
addErrorsToControls(
form: FormGroup,
errors: Record<string, ValidationConstraints>,
defs: Record<string, string>,
)
defs
is our injecting from the backend path to form the control path, for example:
email
=> email
address.country
=> step2.address.country
and we can use a path with dots :)
Component
Let's implement the solution into our example component.
@Component({
selector: 'app-some-form',
providers: [
{
provide: CUSTOM_ERRORS,
useValue: {
customError: 'Some custom error'
},
},
],
})
export class ExampleComponent {
form = new FormGroup({
email: new FormControl('', Validators.required),
});
onSubmit(){
const value = this.form.value;
this.someService.sendQuery({email: value.email}).pipe(
catchError((err: HttpErrorResponse) => {
const flatValidationErrors = createErrorObject(err.error.validation);
addErrorsToControls(this.form, flatValidationErrors, {
// backend properties path => control form path
email: 'email',
});
return throwError(() => err);
})
);
}
}
Enjoy the implementation, if you like the concept, leave a comment :)