Back to overview

Conditional Validator

Angular

Introduction

When working with Angular reactive forms, managing conditional validators for multiple controls can become cumbersome. Traditionally, developers subscribe to a control's valueChanges observable and manually add or remove validators based on certain conditions. This can lead to redundant and verbose code, especially as the number of controls increases.

In this blog post, we'll explore a more elegant solution using a custom utility called ifValidator. This utility simplifies the process of applying validators conditionally, resulting in cleaner and more maintainable code.

The Traditional Approach

Let's first examine the traditional approach to conditional validation in Angular reactive forms. In the example below, we have a form with a choice between installing a new operator or keeping the current one. Depending on the choice, we dynamically add or remove the "required" validator for the apiToken and clusterName controls.

<form clrForm [formGroup]="form">
  <clr-radio-container>
    <label>{{ 'CHOOSE_OPTION' }}</label>
    <clr-radio-wrapper>
      <input type="radio" clrRadio name="choice" value="INSTALL" [formControl]="choice" />
      <label>{{ 'INSTALL_NEW_OPERATOR' }}</label>
    </clr-radio-wrapper>
    <clr-radio-wrapper>
      <input type="radio" clrRadio name="choice" value="CURRENT" [formControl]="choice" />
      <label>{{ 'KEEP_CURRENT_OPERATOR' }}</label>
    </clr-radio-wrapper>
    <clr-control-error> {{ 'REQUIRED' }} </clr-control-error>
  </clr-radio-container>

  <!-- omit apiToken and clusterName control code... -->
</form>
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';

export class ConfigureOperatorComponent {
  private destroyRef = inject(DestroyRef);

  choice = new FormControl<'CURRENT' | 'INSTALL' | null>(null, [Validators.required]);

  form = new FormGroup({
    apiToken: new FormControl(''),
    clusterName: new FormControl(''),
  });

  ngOnInit() {
    this.choice.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((choice) => {
      if (choice === 'INSTALL') {
        this.form.controls.apiToken.addValidators(Validators.required);
        this.form.controls.clusterName.addValidators(Validators.required);
      } else {
        this.form.controls.apiToken.removeValidators(Validators.required);
        this.form.controls.clusterName.removeValidators(Validators.required);
      }

      this.form.controls.apiToken.updateValueAndValidity();
      this.form.controls.clusterName.updateValueAndValidity();
    });
  }
}

Simplifying with ifValidator

To streamline the process, we introduce the ifValidator utility. This utility encapsulates the logic of conditionally applying validators based on a provided condition. Add a input listener like line 5 and line 9, (change)="changeChoice()", we use it to trigger controls' updateValueAndValidity instead of ngOnInit.

<form clrForm [formGroup]="form">
  <clr-radio-container>
    <label>{{ 'CHOOSE_OPTION' }}</label>
    <clr-radio-wrapper>
      <input type="radio" clrRadio name="choice" value="INSTALL" [formControl]="choice" (change)="changeChoice()" />
      <label>{{ 'INSTALL_NEW_OPERATOR' }}</label>
    </clr-radio-wrapper>
    <clr-radio-wrapper>
      <input type="radio" clrRadio name="choice" value="CURRENT" [formControl]="choice" (change)="changeChoice()" />
      <label>{{ 'KEEP_CURRENT_OPERATOR' }}</label>
    </clr-radio-wrapper>
    <clr-control-error> {{ 'REQUIRED' }} </clr-control-error>
  </clr-radio-container>
</form>
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';

export class ConfigureOperatorComponent {
  choice = new FormControl<'CURRENT' | 'INSTALL' | null>(null, [Validators.required]);

  // the first callback is your condition add invoke the second arg (validator)
  private validator = ifValidator(() => this.choice.value === 'INSTALL', [Validators.required]);

  form = new FormGroup({
    apiToken: new FormControl('', this.validator),
    clusterName: new FormControl('', this.validator),
  });

  changeChoice() {
    this.form.controls.apiToken.updateValueAndValidity();
    this.form.controls.clusterName.updateValueAndValidity();
  }
}

ifValidator and ifAsyncValidator utils

Here's the implementation of the ifValidator utility along with its async counterpart ifAsyncValidator. These utilities eliminate the need to manually add or remove validators, making your code more concise and maintainable.

/**
 * Simple Validation with If condition
 * The benefit is that no need to add/remove validators. Only need to trigger updateValueAndValidity.
 */
export function ifValidator(
  condition: (control: FormControl) => boolean,
  validatorFn: ValidatorFn | ValidatorFn[],
): ValidatorFn {
  return (control: AbstractControl) => {
    if (!validatorFn || !condition(<FormControl>control)) {
      return null;
    }
    const validatorFns = Array.isArray(validatorFn) ? (validatorFn as ValidatorFn[]) : [validatorFn];
    return Validators.compose(validatorFns)?.(control) ?? null;
  };
}

/**
 * With Async Validation
 */
export function ifAsyncValidator(
  condition: (control: FormControl) => boolean,
  validatorFn: AsyncValidatorFn,
): AsyncValidatorFn {
  return (control: AbstractControl) => {
    if (!validatorFn || !condition(<FormControl>control)) {
      return of(null);
    }

    return validatorFn(control);
  };
}

By adopting the ifValidator utility, you can enhance the readability and maintainability of your Angular reactive forms, especially when dealing with complex validation scenarios. This approach ensures that your code remains concise, focused, and easy to understand.