Skip to content
Angular ng forms 4 min read

Custom Validators

Angular’s built-in validators cover the common cases — required, email, min length — but real applications need domain rules the framework can’t know about, such as “the password and confirmation must match” or “this username may not contain spaces”. A custom validator is just a function that inspects a control’s value and returns either null (valid) or an errors object (invalid). Because the contract is so small, you can write reusable, parameterised, and cross-field validators with plain TypeScript and no special Angular machinery.

The ValidatorFn contract

Every synchronous validator implements the ValidatorFn type: a function that takes an AbstractControl and returns ValidationErrors | null. Returning null means the control passes; returning an object marks it invalid, and that object’s keys become the error keys you read later via control.hasError('key').

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

export function noWhitespace(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string;
  const isValid = value == null || !/\s/.test(value);
  return isValid ? null : { whitespace: true };
}

You attach a ValidatorFn exactly like a built-in one, passing the function reference (not calling it):

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { noWhitespace } from './validators';

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup.component.html',
})
export class SignupComponent {
  private fb = inject(FormBuilder);

  form = this.fb.nonNullable.group({
    username: ['', [Validators.required, noWhitespace]],
  });
}

Parameterised validators (validator factories)

A bare function works when the rule has no configuration. When the rule needs an argument — a forbidden value, a regex, a numeric range — write a factory that closes over the argument and returns a ValidatorFn. This is the same pattern Validators.minLength(8) uses internally.

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

export function forbiddenValue(forbidden: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const matches = control.value === forbidden;
    return matches ? { forbidden: { value: control.value } } : null;
  };
}

Note that the factory is called when you attach it, because it returns the actual validator:

username: ['', [Validators.required, forbiddenValue('admin')]],

Returning a rich error payload ({ forbidden: { value } }) instead of a bare true lets the template show context-specific messages.

Cross-field validators

A single-control validator can only see one value, so it can’t compare two fields. To validate across controls — password confirmation, date ranges, “at least one of these is filled” — attach the validator to the parent FormGroup instead. The control passed in is then the group, and you read its children with control.get(...).

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

export const passwordsMatch: ValidatorFn = (
  group: AbstractControl,
): ValidationErrors | null => {
  const password = group.get('password')?.value;
  const confirm = group.get('confirm')?.value;
  if (!password || !confirm) return null;
  return password === confirm ? null : { passwordsMismatch: true };
};

Attach it through the group’s options object, the second argument to fb.group:

form = this.fb.nonNullable.group(
  {
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirm: ['', Validators.required],
  },
  { validators: passwordsMatch },
);

The error now lives on the group, not on either child control, so you read it from the group in the template.

<form [formGroup]="form">
  <input type="password" formControlName="password" placeholder="Password" />
  <input type="password" formControlName="confirm" placeholder="Confirm" />

  @if (form.hasError('passwordsMismatch') && form.get('confirm')?.touched) {
    <p class="error">Passwords do not match.</p>
  }
</form>

Group-level errors do not mark the child controls invalid, only the group. If you want the confirm field itself to show an invalid state, call group.get('confirm')?.setErrors({ passwordsMismatch: true }) from within the validator — but always return the error from the function too, or Angular will clear it on the next run.

Reading errors in the template

Whatever object your validator returns surfaces through control.errors and the hasError helper. Pair it with touched/dirty so messages appear only after the user has interacted.

@let username = form.controls.username;

@if (username.invalid && (username.dirty || username.touched)) {
  @if (username.hasError('required')) {
    <p class="error">Username is required.</p>
  } @else if (username.hasError('whitespace')) {
    <p class="error">No spaces allowed.</p>
  } @else if (username.hasError('forbidden')) {
    <p class="error">"{{ username.getError('forbidden').value }}" is reserved.</p>
  }
}

Output:

form.controls.username.errors → { forbidden: { value: 'admin' } }
form.valid                     → false

Sync vs async at a glance

AspectSynchronous (ValidatorFn)Asynchronous (AsyncValidatorFn)
Return typeValidationErrors | nullObservable/Promise of the same
RunsImmediately on every value changeAfter sync validators pass
Use forFormat, ranges, cross-field rulesServer checks (e.g. unique email)
Attached asvalidators optionasyncValidators option
Pending stateNeverSets control.pending while running

If your rule needs a network round-trip, reach for an async validator instead; everything self-contained belongs in a synchronous ValidatorFn.

Best Practices

  • Keep validators pure: derive the result only from control.value, with no side effects, so they’re predictable and reusable across forms.
  • Use a factory function whenever the rule takes configuration, mirroring Validators.minLength(n).
  • Return descriptive error objects ({ range: { min, max, actual } }) rather than true, so templates can render precise messages.
  • Put cross-field rules on the FormGroup, not the children, and read the error from the group in the template.
  • Guard against null/empty values early and return null — let Validators.required own the “is it filled” question so validators compose cleanly.
  • Export validators from a shared validators.ts module and unit-test them as plain functions; they need no TestBed.
Last updated June 14, 2026
Was this helpful?