Enhancing Angular Form Validation with Yup: 
A Comprehensive Guide

Enhancing Angular Form Validation with Yup: A Comprehensive Guide

Angular is a robust framework for building single-page applications, and it comes with an impressive set of features, including native form validation. However, Angular's native form validation has might appear complex for beginners and can be verbose when displaying error messages, among other things.

In this article, we will explore how we can leverage Yup, a JavaScript object schema validator, to enhance Angular form validation, improve readability and enable reusability across other javascript frameworks.

NB: If you are just looking for boilerplate code to get started, you can get it here. You can also view the native Angular form on the native-angular-forms branch.

A Basic Angular Form Setup

Let's first look at a standard Angular form setup using Reactive Forms. Here is an example of a basic signup form:

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import {
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';

@Component({
  selector: 'signup-form',
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent implements OnInit {
  signupForm = new FormGroup({
    name: new FormControl('', Validators.required),
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [
      Validators.required,
      Validators.minLength(8),
      Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[a-zA-Z0-9]+$/),
    ]),
    phone: new FormControl('', [
      Validators.required,
      Validators.pattern(/^\+?\d{1,5}-\d{7,14}$/),
    ]),
  });

  constructor() {}

  onSubmit() {
    console.warn(this.signupForm.value);
  }
}
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
  <label>
    Name:
    <input type="text" formControlName="name" />
    <small
      *ngIf="
        signupForm.controls.name.errors?.['required'] &&
        (signupForm.controls.name.dirty || signupForm.controls.name.touched)
      "
      >Name is required</small
    >
  </label>
  <label>
    Email:
    <input type="email" formControlName="email" />
    <small
      *ngIf="
        signupForm.controls.email.errors?.['required'] &&
        (signupForm.controls.email.dirty || signupForm.controls.email.touched)
      "
      >Email is required</small
    >
    <small
      *ngIf="
        signupForm.controls.email.errors?.['email'] &&
        (signupForm.controls.email.dirty || signupForm.controls.email.touched)
      "
      >Valid email is required</small
    >
  </label>
  <label>
    Password:
    <input type="password" formControlName="password" />
    <small
      *ngIf="
        signupForm.controls.password.errors?.['required'] &&
        (signupForm.controls.password.dirty ||
          signupForm.controls.password.touched)
      "
      >Password is required</small
    >
    <small
      *ngIf="
        signupForm.controls.password.errors?.['minlength'] &&
        (signupForm.controls.password.dirty ||
          signupForm.controls.password.touched)
      "
      >Password must be at least 8 characters long</small
    >
    <small
      *ngIf="
        signupForm.controls.password.errors?.['pattern'] &&
        (signupForm.controls.password.dirty ||
          signupForm.controls.password.touched)
      "
      >Password must have at least one lowercase letter, one uppercase letter,
      and one number</small
    >
  </label>
  <label>
    Phone Number:
    <input type="tel" formControlName="phone" />
    <small
      *ngIf="
        signupForm.controls.phone.errors?.['required'] &&
        (signupForm.controls.phone.dirty || signupForm.controls.phone.touched)
      "
      >Phone number is required</small
    >
    <small
      *ngIf="
        signupForm.controls.phone.errors?.['pattern'] &&
        (signupForm.controls.phone.dirty || signupForm.controls.phone.touched)
      "
      >Phone number should contain only digits</small
    >
  </label>
  <button type="submit" [disabled]="signupForm.invalid">Submit</button>
</form>

The Problem with Native Angular Form Validation

While Angular's native form validation is powerful, it does have its limitations:

  • Readability: As seen in our HTML template, the logic for displaying error messages can become cumbersome. We have to manually check for each potential error on every field, which results in verbose and less readable code.

  • Non-beginner friendly: For newbies to Angular, understanding how validation works can be quite daunting due to the extensive use of reactive forms, custom validators, and error handling.

  • Other Limitations: It cannot be used elsewhere to validate form data and complex error handling with custom error messages is stressful to implement.

So, how can we make form validation more streamlined, reusable, and manageable? Here's where Yup comes in handy.

An Alternative Approach: Yup for Form Validation

Let's look at how we can integrate Yup into our Angular form:

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { YupFormControls, FormHandler } from 'src/utilities/form-handler';
import * as Yup from 'yup';

@Component({
  selector: 'signup-form',
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  signupForm: FormGroup<YupFormControls<ISignupForm>>;

  initialValues: ISignupForm = {
    name: '',
    email: '',
    password: '',
    phone: '',
  };

  validationSchema: Yup.ObjectSchema<ISignupForm> = Yup.object().shape({
    name: Yup.string().required('Name is required').min(2),
    email: Yup.string().email().required('Email is required'),
    password: Yup.string()
      .required('Password is required')
      .min(8, 'Password must be at least 8 characters long')
      .matches(/[a-z]/, 'Password must contain at least one lowercase letter')
      .matches(/[A-Z]/, 'Password must contain at least one uppercase letter')
      .matches(
        /[^a-zA-Z0-9]/,
        'Password must contain at least one special character'
      ),
    phone: Yup.string()
      .required('Phone is required')
      .matches(
        /^\+[1-9]{1,5}\d{7,14}$/,
        'Please enter a valid international number'
      ),
  });

  formError = (controlName: string) => {
    const control = this.signupForm.get(controlName);
    if (control?.pristine && !control.touched) return;
    return this.signupForm?.errors?.[controlName];
  };

  constructor() {
    this.signupForm = FormHandler.controls<ISignupForm>(this.initialValues);
    this.signupForm.setValidators(
      FormHandler.validate<ISignupForm>(this.validationSchema)
    );
  }

  onSubmit() {
    alert('Form submitted successfully!');
  }
}

interface ISignupForm {
  name: string;
  email: string;
  password: string;
  phone: string;
}
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
  <label>
    Name:
    <input type="text" formControlName="name" />
    <small>{{ formError("name") }}</small>
  </label>
  <label>
    Email:
    <input type="email" formControlName="email" />
    <small>{{ formError("email") }}</small>
  </label>
  <label>
    Password:
    <input type="text" formControlName="password" />
    <small>{{ formError("password") }}</small>
  </label>
  <label>
    Phone Number:
    <input type="tel" formControlName="phone" />
    <small>{{ formError("phone") }}</small>
  </label>
  <button type="submit" [disabled]="signupForm.invalid">Submit</button>
</form>

The code above might seem overwhelming at first, but don't worry. We'll break it down piece by piece in the next sections.

Breaking Down the Yup Integration

The FormHandler Class

Our integration starts with a utility class, FormHandler. This class provides two static methods: controls() and validate().

controls() creates the FormGroup for our form. validate(), on the other hand, is a custom Angular validator. It takes a Yup schema and validates the form data against it.

Notice schema.validateSync(group.value, { abortEarly: false }); line. This validation runs synchronously and by setting abortEarly it to false, we ensure that Yup checks for all validation errors, not just the first one it encounters.

import {
  AbstractControl,
  FormControl,
  FormGroup,
  ValidationErrors,
  ValidatorFn,
} from '@angular/forms';
import * as Yup from 'yup';

// Type alias for any object
type AnyObject = { [key: string]: any };

// Type alias for form controls
export type YupFormControls<T> = { [P in keyof T]: FormControl<T[P]> };

export class FormHandler {
  // Generate form controls for the provided fields
  static controls<T extends AnyObject>(
    formFields: T
  ): FormGroup<YupFormControls<T>> {
    const formControls: YupFormControls<T> = {} as YupFormControls<T>;
    for (const [key, value] of Object.entries(formFields)) {
      formControls[key as keyof T] = new FormControl(value);
    }
    return new FormGroup(formControls);
  }

  // Validate a form group using the provided Yup schema
  static validate<T extends Yup.AnyObject>(
    schema: Yup.ObjectSchema<T>
  ): ValidatorFn {
    return (group: AbstractControl): ValidationErrors | null => {
      if (!(group instanceof FormGroup)) return null;

      try {
        schema.validateSync(group.value, { abortEarly: false });
        return null;
      } catch (error) {
        if (!(error instanceof Yup.ValidationError)) throw error;

        const errorObjects = error.inner;
        let filteredErrors: Yup.ValidationError[] = [];
        errorObjects.forEach((obj: Yup.ValidationError) => {
          const isExisting = filteredErrors.some((x) => obj.path === x.path);
          if (!isExisting) return filteredErrors.push(obj);

          filteredErrors = filteredErrors.map((item) => {
            if (item.path === obj.path) item.errors.push(obj.message);
            return item;
          });
          return;
        });
        const errors: ValidationErrors = {};
        filteredErrors.forEach((item: Yup.ValidationError) => {
          if (!item.path) return;
          const formControl = group.get(item.path);
          if (!formControl) return;
          formControl.setErrors({ errors: item.errors[0] });
          errors[item.path] = item.errors[0];
        });
        return errors;
      }
    };
  }
}

Yup Implementation File

In the SignupFormComponent above, we first define our form fields, initial values, and validation schema. We use the FormHandler.controls() method to generate our form controls based on the initial values. We then apply the Yup schema validator using FormHandler.validate(). The formError() method is used to retrieve and display error messages.

Sure, let's break down the code sections and explain each one individually.

signupForm: FormGroup<YupFormControls<ISignupForm>>;

A. This line declares signupForm as an instance of FormGroup. The FormGroup is a class in Angular's reactive forms module that tracks the value and validity state of a group of FormControl instances. The <YupFormControls<ISignupForm>> generic parameter tells Angular that the form controls in this group will adhere to the structure defined by the ISignupForm interface.

initialValues: ISignupForm = {
  name: '',
  email: '',
  password: '',
  phone: '',
};

B. This block of code initializes the form with an empty ISignupForm object. When the form is displayed to the user, all the input fields will be initially empty.

validationSchema: Yup.ObjectSchema<ISignupForm> = Yup.object().shape({
  /* field validation rules */
});

C. This line declares a Yup schema for validating the signupForm. Each field in the ISignupForm object has its own validation rules defined. When the form is submitted, these rules will be checked against the current values of the form fields.

formError = (controlName: string) => {
  const control = this.signupForm.get(controlName);
  if (control?.pristine && !control.touched) return;
  return this.signupForm?.errors?.[controlName];
};

D. The formError function is used to handle form validation errors. It takes as an argument the name of a form control and checks whether that control has been touched and is not pristine (i.e., its value has been changed). If these conditions are met, it retrieves and returns any validation errors associated with that control.

constructor() {
  this.signupForm = FormHandler.controls<ISignupForm>(this.initialValues);
  this.signupForm.setValidators(
    FormHandler.validate<ISignupForm>(this.validationSchema)
  );
}

E. Inside the constructor, we use FormHandler.controls<ISignupForm>(this.initialValues) to initialize signupForm with form controls created from initialValues. Then, we set the validators for signupForm using the FormHandler.validate<ISignupForm>(this.validationSchema) method.

onSubmit() {
  alert('Form submitted successfully!');
}

F. The onSubmit method is called when the user submits the form. In this example, it simply shows an alert saying "Form submitted successfully!", but in a real-world application, this method would typically include logic for handling the submitted form data, such as sending it to a server.

interface ISignupForm {
  name: string;
  email: string;
  password: string;
  phone: string;
}

G. The ISignupForm interface represents the structure of the data for the signup form. It ensures that initialValues, validationSchema, and the actual form controls in signupForm all have the same structure.

These individual parts, when combined, provide an effective and manageable form validation system. Each part has its role, from defining the structure of the form, initializing the form values, and validating the form data, to handling the form submission.

Conclusion

We've seen how to enhance Angular form validation by integrating Yup. Yup provides a more readable, useful, reusable, and expressive approach to form validation than native Angular form validation. While Angular's built-in validation is powerful, using Yup can help to manage complexity as our application grows.
Happy coding!