Get errors from NestJS and inject to Angular forms

·

4 min read

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 :)