Tutorials Logic, IN info@tutorialslogic.com

Angular Form Validation Built in Custom

Validation Boundary

Form validation combines field rules, interaction state, cross-field checks, asynchronous work, and server feedback. This lesson shows where validators belong and when an error should become visible to the user.

Angular provides a powerful validation system for both Reactive and Template-driven forms. Validators can be built-in (required, minLength, email) or custom. Angular tracks the validity state of each control and the form as a whole, making it easy to show error messages and disable submit buttons.

Client validation provides immediate feedback and reduces avoidable requests, but it is not a trust boundary. Repeat every business and security rule on the server because a caller can bypass the Angular form entirely.

Show errors after a field is touched or after submission, associate messages with controls, and use role="alert" or an appropriate live region for feedback that appears dynamically. Do not rely on color alone.

Disabled controls are excluded from FormGroup.value. Use getRawValue only when the submitted contract intentionally includes them, and never confuse disabled UI with server authorization.

Built-in Validator Description Usage
Validators.required Field must not be empty required attribute or Validators.required
Validators.minLength(n) Minimum character count minlength="3" or Validators.minLength(3)
Validators.maxLength(n) Maximum character count maxlength="50" or Validators.maxLength(50)
Validators.email Valid email format email attribute or Validators.email
Validators.pattern(regex) Must match regex pattern Validators.pattern(/^[0-9]+$/)
Validators.min(n) Minimum numeric value Validators.min(0)
Validators.max(n) Maximum numeric value Validators.max(100)

Reactive Validation

In reactive forms, validators are added programmatically in the component class. This gives full control over validation logic and makes it easy to test.

Reactive Form with Validation

Reactive Form with Validation
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';

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

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

    // Convenience getters for template access
    get name()     { return this.form.get('name')!; }
    get email()    { return this.form.get('email')!; }
    get password() { return this.form.get('password')!; }
    get age()      { return this.form.get('age')!; }

    onSubmit() {
        if (this.form.valid) {
            console.log('Form submitted:', this.form.value);
        } else {
            this.form.markAllAsTouched();
        }
    }
}

Reactive Form Validation

Reactive Form Validation
<form [formGroup]="form" (ngSubmit)="onSubmit()">

    <div>
        <label>Name</label>
        <input formControlName="name" />
        @if (name.invalid && name.touched) {
            @if (name.errors?.['required']) {
                <span class="tl-text-danger">Name is required.</span>
            }
            @if (name.errors?.['minlength']) {
                <span class="tl-text-danger">Name must be at least 2 characters.</span>
            }
        }
    </div>

    <div>
        <label>Email</label>
        <input formControlName="email" type="email" />
        @if (email.invalid && email.touched) {
            @if (email.errors?.['required']) {
                <span class="tl-text-danger">Email is required.</span>
            }
            @if (email.errors?.['email']) {
                <span class="tl-text-danger">Enter a valid email address.</span>
            }
        }
    </div>

    <button type="submit" [disabled]="form.invalid">Register</button>

</form>

Custom Validation

When built-in validators are not enough, you can create custom validators. A validator is a function that takes an AbstractControl and returns either null (valid) or a ValidationErrors object (invalid).

Return a stable error key with useful details, such as { dateRange: { start, end } }, so the template can choose an accurate message. Keep synchronous validators pure and free of HTTP calls.

Use an AsyncValidatorFn for a server-backed check and debounce upstream input when appropriate. Angular marks the control pending until the observable or promise completes; ensure the validator completes and handle transport failure separately from a genuine invalid result.

Cross-field rules belong on the nearest FormGroup that owns all participating controls. Display the group error near the related controls and clear it by returning null when the relationship becomes valid.

Custom Validators

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

// 1. No whitespace validator
export function noWhitespaceValidator(): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
        const hasWhitespace = (control.value || '').includes(' ');
        return hasWhitespace ? { whitespace: true } : null;
    };
}

// 2. Password strength validator
export function passwordStrengthValidator(): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
        const value = control.value || '';
        const hasUpper   = /[A-Z]/.test(value);
        const hasLower   = /[a-z]/.test(value);
        const hasNumber  = /[0-9]/.test(value);
        const hasSpecial = /[!@#$%^&*]/.test(value);
        const isStrong   = hasUpper && hasLower && hasNumber && hasSpecial;
        return isStrong ? null : { weakPassword: { hasUpper, hasLower, hasNumber, hasSpecial } };
    };
}

// 3. Cross-field validator - passwords must match
export function passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
    const password        = control.get('password');
    const confirmPassword = control.get('confirmPassword');
    if (!password || !confirmPassword) return null;
    return password.value === confirmPassword.value ? null : { passwordMismatch: true };
}

Custom Validation Setup

Custom Validation Setup
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { noWhitespaceValidator, passwordStrengthValidator, passwordMatchValidator } from './custom-validators';

@Component({
    selector: 'app-signup',
    standalone: true,
    imports: [ReactiveFormsModule],
    template: `
        <form [formGroup]="form" (ngSubmit)="onSubmit()">
            <input formControlName="username" placeholder="Username" />
            @if (form.get('username')?.errors?.['whitespace']) {
                <span class="tl-text-danger">No spaces allowed.</span>
            }

            <input formControlName="password" type="password" placeholder="Password" />
            @if (form.get('password')?.errors?.['weakPassword']) {
                <span class="tl-text-danger">Password needs uppercase, lowercase, number and special char.</span>
            }

            <input formControlName="confirmPassword" type="password" placeholder="Confirm Password" />
            @if (form.errors?.['passwordMismatch']) {
                <span class="tl-text-danger">Passwords do not match.</span>
            }

            <button type="submit" [disabled]="form.invalid">Sign Up</button>
        </form>
    `
})
export class SignupComponent {
    private fb = inject(FormBuilder);

    form = this.fb.group({
        username: ['', [Validators.required, noWhitespaceValidator()]],
        password: ['', [Validators.required, passwordStrengthValidator()]],
        confirmPassword: ['', Validators.required]
    }, { validators: passwordMatchValidator });

    onSubmit() {
        if (this.form.valid) console.log(this.form.value);
    }
}

Control State

Control state answers two separate questions: whether the value satisfies the rules and whether the user has interacted enough to need feedback. Keep an attempted-submit flag at the form boundary so invalid untouched fields become visible after submission.

Use updateOn: "blur" when validation should wait until a field loses focus and updateOn: "submit" for forms that should validate as a unit. This changes when value and validation updates occur, so test keyboard, paste, and submit behavior rather than using it only to hide messages.

State Property Meaning
Pristine control.pristine User has not changed the value yet
Dirty control.dirty User has changed the value
Untouched control.untouched User has not focused and left the field
Touched control.touched User has focused and left the field
Valid control.valid All validators pass
Invalid control.invalid At least one validator fails
Pending control.pending Async validator is running
  • pending means asynchronous validation is still running; do not show success or submit during that state.
  • disabled controls are excluded from value and validation; disabled is not an authorization mechanism.
  • Call markAllAsTouched after an invalid submit and focus or summarize the first invalid field accessibly.

Asynchronous Username Validator

Asynchronous Username Validator
export function usernameAvailable(api: AccountsApi): AsyncValidatorFn {
  return control => {
    const value = String(control.value ?? '').trim();
    if (!value) return of(null);

    return timer(250).pipe(
      switchMap(() => api.usernameExists(value)),
      map(exists => exists ? { usernameTaken: { value } } : null),
      catchError(() => of({ availabilityUnavailable: true })),
      take(1)
    );
  };
}

readonly username = new FormControl('', {
  nonNullable: true,
  validators: [Validators.required],
  asyncValidators: [usernameAvailable(inject(AccountsApi))],
  updateOn: 'blur'
});

Apply and Clear Server Validation Errors

Apply and Clear Server Validation Errors
applyServerErrors(errors: Record<string, string>): void {
  for (const [field, message] of Object.entries(errors)) {
    const control = this.form.get(field);
    if (!control) continue;

    control.setErrors({ ...control.errors, server: message });
  }
}

clearServerError(control: AbstractControl): void {
  const { server, ...remaining } = control.errors ?? {};
  control.setErrors(Object.keys(remaining).length ? remaining : null);
}

Signal Forms Validation

Signal Forms define validation in a schema over a signal-backed model. Use built-in schema rules for field constraints, validate a group path for cross-field rules, and read the field state for errors, touched, dirty, pending, and validity.

Keep one form model per screen and map it to the server command at submission. Server authorization and business validation remain authoritative even when the client schema matches the same rules.

  • Bind controls with FormField so value and interaction state stay connected to the field tree.
  • Render error messages from stable error kinds rather than hard-coding one message for every failure.
  • Choose Signal Forms for a signal-first field model; established reactive forms do not require a rewrite.

Signal Form Schema

Signal Form Schema
interface Credentials {
  email: string;
  password: string;
}

const model = signal<Credentials>({
  email: '',
  password: ''
});

const credentialsSchema = schema<Credentials>(path => {
  required(path.email, { message: 'Email is required' });
  email(path.email, { message: 'Enter a valid email' });
  minLength(path.password, 12, {
    message: 'Use at least 12 characters'
  });
});

const credentialsForm = form(model, credentialsSchema);
Confirm the page outcome

Validation Review

5 checks
  • I can place field, cross-field, asynchronous, and server validation at the boundary that owns the rule.
  • My validators return null for valid values and stable, typed error details for invalid values.
  • I handle touched, submitted, dirty, pending, disabled, and updateOn behavior deliberately.
  • Error messages are associated with controls, keyboard-accessible, and not communicated by color alone.
  • I can test validator functions, form integration, cancellation, server errors, and the submitted payload.

Validation Review Questions

A validator must return null for valid input and a ValidationErrors object only for invalid input. Common bugs include returning an empty object, testing stale component state instead of control.value, or assuming every value is a string.

Usually after the field is touched, or after an attempted submit. A required control is invalid from the moment it is created; that does not mean the user needs an error before interacting.

The validator may be attached to the password control even though it depends on two controls. Put cross-field validation on the containing FormGroup so either child change triggers reevaluation, then expose a group-level mismatch error.

Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.