Building Robust Form Validation in Angular with Custom Validators
Alireza Razinejad

Alireza Razinejad @ussdlover

About: Developer ! In love with JS and my mistress is TS. My philosophy is Angular so NestJs is in back pocket.

Location:
L, Front.
Joined:
Feb 27, 2020

Building Robust Form Validation in Angular with Custom Validators

Publish Date: Aug 12
0 0

Introduction

Form validation is a critical aspect of modern web applications, especially when dealing with complex business logic that goes beyond simple required field checks. Angular's reactive forms provide a powerful foundation, but sometimes you need to implement custom validation logic that handles intricate field relationships and dependencies.

In this article, I'll walk you through creating a sophisticated form validation system using custom validators in Angular. We'll build a Product Configuration Form that demonstrates advanced validation patterns you can apply to any complex form scenario.

The Challenge

Imagine you're building a product configuration system where users can:

  • Select a product category
  • Choose a product type
  • Set pricing options
  • Configure availability settings

The challenge is that these fields have complex interdependencies:

  • Product category affects which product types are available
  • Product type determines pricing model options
  • Pricing model affects availability settings
  • Some fields must be filled together, while others become unavailable based on selections

Setting Up the Project

First, let's create our component structure:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'app-product-config',
  templateUrl: './product-config.component.html',
  styleUrls: ['./product-config.component.scss']
})
export class ProductConfigComponent implements OnInit {
  productForm: FormGroup;

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.initializeForm();
    this.setupFieldListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating Custom Validators

1. Cross-Field Validation

Our first validator ensures that when a product type is selected, the appropriate pricing model is chosen:

function pricingModelValidator(group: AbstractControl): ValidationErrors | null {
  const productType = group.get('productType')?.value;
  const pricingModel = group.get('pricingModel')?.value;

  if (productType === 'subscription' && pricingModel !== 'monthly' && pricingModel !== 'yearly') {
    return { invalidPricingForSubscription: true };
  }

  if (productType === 'oneTime' && pricingModel !== 'fixed' && pricingModel !== 'tiered') {
    return { invalidPricingForOneTime: true };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

2. Dependent Fields Validator

This validator ensures that when availability settings are configured, both start and end dates are provided:

function availabilityDatesValidator(group: AbstractControl): ValidationErrors | null {
  const startDate = group.get('availabilityStart')?.value;
  const endDate = group.get('availabilityEnd')?.value;
  const isLimitedAvailability = group.get('limitedAvailability')?.value;

  if (isLimitedAvailability && ((startDate && !endDate) || (!startDate && endDate))) {
    return { availabilityDatesIncomplete: true };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

3. Business Rule Validator

This validator implements complex business logic for product configuration:

function productConfigurationValidator(group: AbstractControl): ValidationErrors | null {
  const category = group.get('category')?.value;
  const productType = group.get('productType')?.value;
  const pricingModel = group.get('pricingModel')?.value;

  // Premium categories require subscription-based products
  if (category === 'premium' && productType === 'oneTime') {
    return { premiumCategoryRequiresSubscription: true };
  }

  // Enterprise products must have tiered pricing
  if (category === 'enterprise' && pricingModel !== 'tiered') {
    return { enterpriseRequiresTieredPricing: true };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Building the Form

Now let's create our form with all the validators:

private initializeForm(): void {
  this.productForm = this.fb.group({
    category: ['', Validators.required],
    productType: ['', Validators.required],
    pricingModel: ['', Validators.required],
    limitedAvailability: [false],
    availabilityStart: [''],
    availabilityEnd: [''],
    productName: ['', [Validators.required, Validators.minLength(3)]],
    description: ['', Validators.maxLength(500)]
  }, {
    validators: [
      pricingModelValidator,
      availabilityDatesValidator,
      productConfigurationValidator
    ]
  });
}
Enter fullscreen mode Exit fullscreen mode

Implementing Dynamic Field Behavior

The real power comes from implementing dynamic field enabling/disabling based on user selections:

private setupFieldListeners(): void {
  // Listen to category changes
  this.productForm.get('category')?.valueChanges.subscribe(category => {
    this.updateFieldAvailability(category);
  });

  // Listen to product type changes
  this.productForm.get('productType')?.valueChanges.subscribe(productType => {
    this.updatePricingOptions(productType);
  });

  // Listen to availability changes
  this.productForm.get('limitedAvailability')?.valueChanges.subscribe(isLimited => {
    this.updateAvailabilityFields(isLimited);
  });
}

private updateFieldAvailability(category: string): void {
  const productTypeControl = this.productForm.get('productType');
  const pricingModelControl = this.productForm.get('pricingModel');

  if (category === 'premium') {
    // Premium categories only support subscription products
    productTypeControl?.setValue('subscription');
    productTypeControl?.disable({ emitEvent: false });
    this.updatePricingOptions('subscription');
  } else {
    productTypeControl?.enable({ emitEvent: false });
    pricingModelControl?.enable({ emitEvent: false });
  }
}

private updatePricingOptions(productType: string): void {
  const pricingModelControl = this.productForm.get('pricingModel');

  if (productType === 'subscription') {
    pricingModelControl?.setValue('monthly');
    // Update available pricing options
  } else if (productType === 'oneTime') {
    pricingModelControl?.setValue('fixed');
    // Update available pricing options
  }
}

private updateAvailabilityFields(isLimited: boolean): void {
  const startDateControl = this.productForm.get('availabilityStart');
  const endDateControl = this.productForm.get('availabilityEnd');

  if (isLimited) {
    startDateControl?.enable();
    endDateControl?.enable();
    startDateControl?.setValidators([Validators.required]);
    endDateControl?.setValidators([Validators.required]);
  } else {
    startDateControl?.disable();
    endDateControl?.disable();
    startDateControl?.clearValidators();
    endDateControl?.clearValidators();
    startDateControl?.setValue('');
    endDateControl?.setValue('');
  }

  startDateControl?.updateValueAndValidity();
  endDateControl?.updateValueAndValidity();
}
Enter fullscreen mode Exit fullscreen mode

Handling Form State

We need a centralized method to manage field states:

private updateFormState(): void {
  const category = this.productForm.get('category')?.value;
  const productType = this.productForm.get('productType')?.value;
  const isLimitedAvailability = this.productForm.get('limitedAvailability')?.value;

  // Reset all fields to enabled first
  this.enableAllFields();

  // Apply business rules
  if (category === 'premium') {
    this.productForm.get('productType')?.disable({ emitEvent: false });
  }

  if (productType === 'subscription') {
    this.productForm.get('pricingModel')?.disable({ emitEvent: false });
  }

  if (!isLimitedAvailability) {
    this.productForm.get('availabilityStart')?.disable({ emitEvent: false });
    this.productForm.get('availabilityEnd')?.disable({ emitEvent: false });
  }
}

private enableAllFields(): void {
  Object.keys(this.productForm.controls).forEach(key => {
    this.productForm.get(key)?.enable({ emitEvent: false });
  });
}
Enter fullscreen mode Exit fullscreen mode

Displaying Validation Errors

In your template, you can now show specific validation errors:

<div class="form-group">
  <label for="pricingModel">Pricing Model</label>
  <select formControlName="pricingModel" class="form-control">
    <option value="">Select Pricing Model</option>
    <option value="monthly">Monthly Subscription</option>
    <option value="yearly">Yearly Subscription</option>
    <option value="fixed">Fixed Price</option>
    <option value="tiered">Tiered Pricing</option>
  </select>

  <div *ngIf="productForm.errors?.['invalidPricingForSubscription']" class="error-message">
    Subscription products must use monthly or yearly pricing
  </div>

  <div *ngIf="productForm.errors?.['invalidPricingForOneTime']" class="error-message">
    One-time products must use fixed or tiered pricing
  </div>

  <div *ngIf="productForm.errors?.['enterpriseRequiresTieredPricing']" class="error-message">
    Enterprise products must use tiered pricing model
  </div>
</div>

<div class="form-group">
  <label>
    <input type="checkbox" formControlName="limitedAvailability">
    Limited Availability
  </label>

  <div *ngIf="productForm.errors?.['availabilityDatesIncomplete']" class="error-message">
    Both start and end dates are required for limited availability
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Best Practices and Tips

1. Use emitEvent: false Strategically

When programmatically updating form values, use { emitEvent: false } to prevent infinite validation loops:

this.productForm.patchValue({
  productType: 'subscription'
}, { emitEvent: false });
Enter fullscreen mode Exit fullscreen mode

2. Centralize Validation Logic

Create a single method that handles all field state updates to maintain consistency:

private updateAllFieldStates(): void {
  this.updateFieldAvailability(this.productForm.get('category')?.value);
  this.updatePricingOptions(this.productForm.get('productType')?.value);
  this.updateAvailabilityFields(this.productForm.get('limitedAvailability')?.value);
}
Enter fullscreen mode Exit fullscreen mode

3. Test Your Validators

Custom validators should be thoroughly tested:

describe('Product Configuration Validators', () => {
  it('should validate subscription pricing for premium category', () => {
    const form = new FormGroup({
      category: new FormControl('premium'),
      productType: new FormControl('subscription'),
      pricingModel: new FormControl('monthly')
    }, [productConfigurationValidator]);

    expect(form.errors).toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

4. Handle Async Validation

For complex business rules that require API calls:

function asyncProductValidator(): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    return this.productService.validateConfiguration(control.value).pipe(
      map(isValid => isValid ? null : { invalidConfiguration: true }),
      catchError(() => of(null))
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Custom form validators in Angular provide a powerful way to implement complex business logic while maintaining clean, maintainable code. By combining multiple validators, dynamic field behavior, and centralized state management, you can create forms that not only validate user input but also guide users through complex workflows.

The key is to think about your validation logic as a system rather than individual field checks. This approach makes your forms more intelligent, user-friendly, and aligned with business requirements.

Remember to:

  • Keep validators focused and single-purpose
  • Use composition to build complex validation logic
  • Centralize field state management
  • Test your validation logic thoroughly
  • Consider performance implications of complex validators

With these patterns, you can build forms that handle even the most complex business requirements while maintaining excellent user experience and code quality.

Comments 0 total

    Add comment