Reader Stacks

Angular Reactive Forms: Building Forms and Adding Custom Validators

A custom validator function follows one fixed contract — take a control, return null or an error object — which is exactly what makes it composable alongside Angular's own built-in validators.

Reactive forms build a form's structure and validation rules directly in the component class rather than the template — genuinely more testable and scalable than template-driven forms for anything beyond a very simple form, and a custom validator follows a small, fixed contract that makes it composable with Angular's built-in ones.

Building a basic reactive form

form = this.fb.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
    age: [null, [Validators.required, Validators.min(18)]],
});

Writing a custom validator function

function forbiddenUsernameValidator(control: AbstractControl): ValidationErrors | null {
    const forbidden = ['admin', 'root', 'superuser'];
    return forbidden.includes(control.value?.toLowerCase()) ? { forbiddenUsername: true } : null;
}
form = this.fb.group({
    username: ['', [Validators.required, forbiddenUsernameValidator]],
});

A validator function follows one fixed contract: it takes an AbstractControl and returns either null (valid) or an object describing the specific error (invalid) — this exact shape is what makes a custom validator composable alongside Angular's built-in ones in the same array, with no special wiring needed to combine them.

A custom validator that needs a configurable parameter

function minWordCountValidator(minWords: number): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
        const wordCount = control.value?.trim().split(/\s+/).filter(Boolean).length ?? 0;
        return wordCount < minWords ? { minWordCount: { required: minWords, actual: wordCount } } : null;
    };
}
description: ['', minWordCountValidator(10)]

Wrapping the actual validator function inside an outer function that accepts a parameter (a "validator factory") is the standard pattern for a configurable custom validator — the outer function's job is only to close over the parameter and return the real validator function Angular actually calls.

A cross-field validator, applied at the form group level

function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
    const password = group.get('password')?.value;
    const confirmPassword = group.get('confirmPassword')?.value;
    return password === confirmPassword ? null : { passwordMismatch: true };
}
form = this.fb.group({
    password: ['', Validators.required],
    confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });

A cross-field validator (comparing two sibling fields, like a password confirmation) needs to be applied to the entire FormGroup, not one individual field's control — this is exactly why it's passed as the group's own validators option rather than inside a single field's validator array.

Displaying a custom validator's error message

That username is not allowed.
Passwords do not match.

An asynchronous validator, for a check requiring a server call

function uniqueEmailValidator(userService: UserService): AsyncValidatorFn {
    return (control: AbstractControl) => {
        return userService.checkEmailExists(control.value).pipe(
            map(exists => (exists ? { emailTaken: true } : null))
        );
    };
}
email: ['', [Validators.required, Validators.email], [uniqueEmailValidator(this.userService)]]

An async validator is passed as the third array in the control's configuration, distinct from the synchronous validators array — necessary since checking whether an email is already taken requires an actual API call, which can't happen synchronously the way a length or pattern check can.