A Practical Guide To Angular Reactive Forms And Custom Validators

Angular reactive forms give you a structured way to model user input, validate it, and respond to changes in a predictable manner. Instead of placing most of the form logic in a template, you define controls and rules in TypeScript. That approach suits applications that need reliable validation, reusable components, dynamic fields, and clear unit tests.

The basic building blocks are FormControl, FormGroup, and FormArray. A control stores one value, a group combines related controls, and an array handles a variable number of controls. Angular’s built-in validators cover common cases such as required values, minimum lengths, email formats, and numeric ranges.

Real applications soon need rules that are specific to the business. A username may need to exclude reserved words, a booking date may need to fall within business hours, or two password fields may need to match. Custom validators allow you to express those requirements without scattering conditional checks throughout the component.

This guide focuses on typed reactive forms, synchronous and asynchronous validation, cross-field rules, error messages, and testing. The examples use modern Angular patterns, while remaining familiar to developers maintaining existing applications for teams in Sydney, Melbourne, Brisbane, or elsewhere in Australia.

Building A Reactive Form

A reactive form starts with a form model in the component class. The following example creates a registration form with typed controls and built-in validators:

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

@Component({
  selector: 'app-registration',
  templateUrl: './registration.component.html'
})
export class RegistrationComponent {
  registrationForm: FormGroup;

  constructor(private formBuilder: NonNullableFormBuilder) {
    this.registrationForm = this.formBuilder.group({
      name: ['', [Validators.required, Validators.maxLength(80)]],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(12)]]
    });
  }

  submit(): void {
    if (this.registrationForm.invalid) {
      this.registrationForm.markAllAsTouched();
      return;
    }

    console.log(this.registrationForm.getRawValue());
  }
}

NonNullableFormBuilder keeps control values from becoming null, which reduces defensive checks in application code. For new projects, typed forms make it easier for the compiler to identify incorrect control names and incompatible values. If you are working in an older codebase, untyped forms can still use the same validator concepts.

The template should associate each control with the form model and show feedback at an appropriate time:

<form [formGroup]="registrationForm" (ngSubmit)="submit()">
  <label for="email">Email address</label>
  <input id="email" type="email" formControlName="email">

  <p *ngIf="registrationForm.controls.email.touched &&
            registrationForm.controls.email.hasError('required')">
    Email is required.
  </p>

  <p *ngIf="registrationForm.controls.email.touched &&
            registrationForm.controls.email.hasError('email')">
    Enter a valid email address.
  </p>

  <button type="submit">Create account</button>
</form>

A control can be dirty, touched, pristine, untouched, valid, or invalid. These states help you avoid displaying errors before someone has interacted with a field. Calling markAllAsTouched() on submission gives users useful feedback when they attempt to submit an incomplete form.

Writing A Synchronous Custom Validator

A synchronous validator is a function that receives an AbstractControl and returns either null or a validation error object. Returning null means the value passes. Returning an object marks the control as invalid and provides a key that the template can inspect.

Here is a validator that rejects values containing a reserved word:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function forbiddenWordValidator(word: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = String(control.value ?? '').trim().toLowerCase();

    if (!value || !value.includes(word.toLowerCase())) {
      return null;
    }

    return {
      forbiddenWord: {
        word
      }
    };
  };
}

The outer function accepts configuration and returns the actual validator function. This factory pattern makes a rule reusable. You could use it for a display name, project title, or company field without hard-coding one particular value.

Apply the validator alongside Angular’s built-in rules:

this.registrationForm = this.formBuilder.group({
  name: [
    '',
    [
      Validators.required,
      Validators.maxLength(80),
      forbiddenWordValidator('admin')
    ]
  ]
});

The returned error object can contain useful metadata. In this case, the prohibited word is available as errors?.['forbiddenWord'].word, although user-facing text should normally be produced through a dedicated error-message method rather than displayed directly from the object.

A validator should handle empty values deliberately. Many custom rules return null for an empty value and leave the required check to Validators.required. This keeps responsibilities separate. If the rule itself requires a non-empty value, document that behaviour and test it clearly.

Validating Related Controls

Some business rules apply to a combination of fields rather than one field in isolation. Password confirmation is a common example. A group-level validator can compare two controls and attach an error to the FormGroup.

export function matchingFieldsValidator(
  firstName: string,
  secondName: string
): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const first = group.get(firstName)?.value;
    const second = group.get(secondName)?.value;

    if (first === second) {
      return null;
    }

    return { fieldsDoNotMatch: true };
  };
}

Use it when creating the group:

this.registrationForm = this.formBuilder.group(
  {
    password: ['', [Validators.required, Validators.minLength(12)]],
    confirmPassword: ['', Validators.required]
  },
  {
    validators: matchingFieldsValidator('password', 'confirmPassword')
  }
);

The error belongs to the group, so the template should check the group rather than only the confirmation control:

<p *ngIf="registrationForm.hasError('fieldsDoNotMatch') &&
          registrationForm.controls.confirmPassword.touched">
  Passwords must match.
</p>

You can choose to copy the group error onto a child control, but doing so requires extra care when values change or validators are removed. Keeping the error at the level where the rule is defined usually produces a simpler form model.

Cross-field validation is useful for Australian-specific workflows as well. A form might require an end date after a start date, a delivery postcode that matches a selected state, or a billing address when a customer chooses an invoice payment method. These rules are easier to maintain when represented as validators rather than template event handlers.

Handling Asynchronous Validation

Some checks require a server call. A username may need to be checked against an existing account, or a discount code may need to be verified by an API. Angular supports this through AsyncValidatorFn, which returns an observable or promise.

import {
  AbstractControl,
  AsyncValidatorFn,
  ValidationErrors
} from '@angular/forms';
import { catchError, map, of, timer } from 'rxjs';

export function usernameAvailableValidator(
  userService: UserService
): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return timer(300).pipe(
      switchMap(() => userService.isUsernameAvailable(control.value)),
      map(isAvailable =>
        isAvailable ? null : { usernameTaken: true }
      ),
      catchError(() => of({ availabilityCheckFailed: true }))
    );
  };
}

The example assumes that switchMap has been imported from rxjs. The short delay reduces requests while someone is typing, although production applications should also consider cancellation, caching, and server-side rate limits.

Attach asynchronous validators separately from synchronous validators:

username: new FormControl(
  '',
  {
    validators: [Validators.required, Validators.minLength(3)],
    asyncValidators: [usernameAvailableValidator(this.userService)],
    updateOn: 'blur'
  }
)

Using updateOn: 'blur' is often more appropriate than checking on every keystroke. It helps control network traffic and avoids showing a temporary “taken” message while a user is still entering a value. The control’s pending state can drive a loading message:

<span *ngIf="registrationForm.controls.username.pending">
  Checking availability…
</span>

Client-side validation improves the user experience, but it cannot replace server-side validation. An API must validate submitted data again because browsers and clients can be modified. When Angular forms connect to .NET or other backend services, keep validation rules aligned without assuming that a front-end rule is a security boundary. For wider integration reading, a middleware reference may help when tracing how requests move through server-side components.

Displaying Errors And Improving Accessibility

A large form becomes difficult to maintain when every error message is written as a separate conditional block. A small helper can translate validation keys into readable messages:

getErrorMessage(
  control: AbstractControl,
  label: string
): string | null {
  if (!control.touched || !control.errors) {
    return null;
  }

  if (control.hasError('required')) {
    return `${label} is required.`;
  }

  if (control.hasError('email')) {
    return `Enter a valid ${label.toLowerCase()}.`;
  }

  if (control.hasError('minlength')) {
    const requiredLength = control.getError('minlength').requiredLength;
    return `${label} must be at least ${requiredLength} characters.`;
  }

  if (control.hasError('forbiddenWord')) {
    return `${label} contains a reserved word.`;
  }

  return `Check the ${label.toLowerCase()} and try again.`;
}

A reusable error component can take an AbstractControl and a label, keeping field templates concise. In larger applications, a message map or translation service is preferable, especially when the product supports multiple languages or regional variations.

Accessibility needs to be part of the form design. Associate every input with a visible label, connect help text and errors through aria-describedby, and set aria-invalid="true" when a control has an error. Do not rely solely on red borders or colour because users with low vision may not distinguish them.

Australian services commonly collect addresses, mobile numbers, dates, and postcodes. Avoid assuming that an Australian mobile number is the only valid phone format, and be clear about whether a date means day-month-year. If a postcode is required, validate its shape as a convenience, but confirm the address on the server or through a suitable address service before fulfilment.

Testing Custom Validation Rules

Validators are small units of business logic and are excellent candidates for focused tests. Test valid values, invalid values, empty values, configuration options, and edge cases. A Jasmine test for the reserved-word validator might look like this:

describe('forbiddenWordValidator', () => {
  const validator = forbiddenWordValidator('admin');

  it('returns null for an acceptable value', () => {
    const control = new FormControl('project-owner');

    expect(validator(control)).toBeNull();
  });

  it('returns an error when the word is present', () => {
    const control = new FormControl('Admin account');

    expect(validator(control)).toEqual({
      forbiddenWord: { word: 'admin' }
    });
  });

  it('ignores an empty value', () => {
    const control = new FormControl('');

    expect(validator(control)).toBeNull();
  });
});

Group-level validators need tests for both matching and non-matching values. Also test changes after the form has been created, because a validator that reads stale state can appear correct during initialisation while failing during normal use.

Component tests should verify that the form becomes invalid when expected, that error messages appear after a control is touched, and that valid data reaches the submit handler. Async validators need tests for available values, unavailable values, request failures, and the pending state.

Keep tests independent of implementation details where possible. A test should care that an invalid username produces the expected error key, not whether the validator uses a particular RxJS operator internally. This makes refactoring safer as the Angular application evolves.

Recommendations For Maintainable Form Validation

A consistent approach prevents form code from becoming a collection of special cases. Use these practices when designing new forms or reviewing an existing Angular project:

Review validation messages with designers, product owners, and support teams before release. A message that makes sense to a developer may not tell a customer in Perth or Adelaide how to fix the problem. Clear labels, practical examples, and preserved input values reduce frustration more effectively than adding another complex rule.

Reactive forms are most valuable when the form model remains understandable. Prefer a few composable validators over a single function that knows about every field, API response, and display message. That separation keeps Angular components easier to test and gives future developers a clear place to change each rule.

Build one small form using these patterns, then add tests before expanding it with conditional fields or asynchronous checks. Once the model, validator contracts, and error presentation are working together, apply the same structure across your application so every form offers consistent feedback and dependable data handling.