Skip to content

Fix: Angular Form Validation Not Working — Validators Not Triggering

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Angular form validation not working — Reactive Forms vs Template-Driven, custom validators, async validators, touched/dirty state, and error message display.

The Problem

Angular form validators are set up but don’t show errors:

this.form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
});
// form.invalid is true but no error message shows in the template

Or form.get('email')?.errors is null even when the field is empty:

// In the component
console.log(this.form.get('email')?.errors);  // null
// Despite email being empty and required validator being set

Or a custom validator never fires:

// Custom validator applied to the FormControl
email: ['', myCustomValidator]
// myCustomValidator never runs

Or the form submits even though validation fails:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <button type="submit">Submit</button>
  <!-- Form submits even when form.invalid is true -->
</form>

Why This Happens

Angular has two form approaches — Reactive Forms and Template-Driven Forms — with different APIs. Mixing them or using the wrong approach causes validators to silently fail.

The root issue is that Angular’s form system is module-driven. Reactive Forms require ReactiveFormsModule and Template-Driven Forms require FormsModule. Using the wrong module, or forgetting the import entirely, causes validation to silently do nothing. No error, no warning in the console — the form just behaves as if no validators exist.

Even when the module is correct, the error display logic in the template is a separate source of bugs. Validators actually run from the very first render, but the template usually guards error messages behind touched or dirty checks. If those checks are missing, errors show immediately on page load. If the checks are wrong (e.g., checking dirty when the validator should fire on blur), errors never appear at all.

Specific failure patterns:

  • Module not imported — Reactive Forms require ReactiveFormsModule; Template-Driven requires FormsModule. Using FormGroup without importing ReactiveFormsModule causes all validation to silently fail.
  • Error shown before user interaction — by default, Angular shows a control as invalid from the start (before the user touches it). If the template checks hasError without checking touched or dirty, the UX shows errors immediately on page load.
  • Custom validator wrong return value — validators must return { [key: string]: any } | null. Returning false or undefined instead of null for valid inputs causes the control to always appear invalid.
  • Async validator not returning an Observable or Promise — async validators must return Observable<ValidationErrors | null> or Promise<ValidationErrors | null>.
  • Template variable used incorrectly — using #email as a template reference variable without ="ngModel" doesn’t give access to the control’s validation state.
  • ngSubmit not preventing default — in Template-Driven forms, the form still submits HTML-natively if Angular’s submit handling isn’t wired correctly.

Diagnostic Timeline

Your first guess is “add Validators.required” — but it is already there. The field is empty, the validator is set, yet the template shows no error. This timeline walks through the actual debugging steps.

Minute 0 — Check the module import. Open the module or standalone component where the form lives. Verify the correct module is imported:

// For Reactive Forms — must have ReactiveFormsModule
@Component({
  standalone: true,
  imports: [ReactiveFormsModule],  // ← Check this line exists
  template: `...`,
})

If ReactiveFormsModule is missing, the [formGroup] and formControlName directives are silently ignored. Angular renders the form as plain HTML — no binding, no validation.

Minute 2 — Log the control errors. In the component’s ngOnInit or after form creation, log the control state:

ngOnInit() {
  this.form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
  });

  const ctrl = this.form.get('email');
  console.log('errors:', ctrl?.errors);     // Should be { required: true }
  console.log('status:', ctrl?.status);     // Should be 'INVALID'
  console.log('touched:', ctrl?.touched);   // false (no user interaction yet)
  console.log('dirty:', ctrl?.dirty);       // false
}

If errors is null and status is 'VALID' on an empty required field, the validator is not attached. Check that the FormBuilder.group() call uses the array syntax correctly — ['', [Validators.required]] (array of validators as the second element), not ['', Validators.required] (which works for a single validator but is easy to break when adding more).

Minute 5 — Check the template guard. The most common cause of “validator is set but error never shows” is the template condition. Open the template and look at the *ngIf:

<!-- This never shows because touched is false until the user clicks and leaves the field -->
<div *ngIf="form.get('email')?.errors?.['required'] && form.get('email')?.touched">
  Email is required
</div>

To test whether the issue is the template guard, temporarily remove the touched check. If the error appears, the validator works — the problem is the UX timing.

Minute 8 — Distinguish Reactive vs Template-Driven. If the form uses both [formGroup] and ngModel on the same input, Angular logs a deprecation warning (Angular 6+) and may silently break validation. Search the template for ngModel directives that coexist with formControlName:

<!-- WRONG — mixing approaches -->
<input formControlName="email" [(ngModel)]="emailValue" />

Remove one or the other. Reactive and Template-Driven forms cannot share the same input.

Minute 12 — Verify FormModule vs ReactiveFormsModule. If you import FormsModule but use [formGroup], nothing happens. The reverse — importing ReactiveFormsModule but using ngModel — also silently fails. Match the module to the approach.

Fix 1: Import the Right Module

Reactive Forms and Template-Driven Forms each need their own module:

// app.module.ts (NgModule approach)
import { ReactiveFormsModule, FormsModule } from '@angular/forms';

@NgModule({
  imports: [
    ReactiveFormsModule,  // For Reactive Forms (FormGroup, FormControl, FormBuilder)
    FormsModule,          // For Template-Driven Forms (ngModel, ngForm)
  ],
})
export class AppModule {}
// Standalone component (Angular 14+)
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],  // Import in the component itself
  template: `...`,
})
export class LoginComponent {}

Verify you’re using the right form approach:

ApproachDirectiveModule
Reactive Forms[formGroup], formControlName, [formControl]ReactiveFormsModule
Template-DrivenngModel, ngForm, ngModelGroupFormsModule

Mixing [formGroup] with ngModel on the same form is a common mistake that breaks validation.

Fix 2: Show Errors Only After User Interaction

Validators run from the start, but showing errors before the user interacts is bad UX. Check touched or dirty:

<!-- BAD — shows error immediately on page load -->
<input formControlName="email" />
<div *ngIf="form.get('email')?.errors?.['required']">Email is required</div>

<!-- GOOD — only show after user has touched the field -->
<input formControlName="email" />
<div *ngIf="form.get('email')?.errors?.['required'] && form.get('email')?.touched">
  Email is required
</div>

<!-- OR: show all errors after the user touches the field -->
<input formControlName="email" (blur)="onBlur()" />
<ng-container *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
  <div *ngIf="form.get('email')?.errors?.['required']">Email is required</div>
  <div *ngIf="form.get('email')?.errors?.['email']">Invalid email format</div>
  <div *ngIf="form.get('email')?.errors?.['minlength']">
    Minimum {{ form.get('email')?.errors?.['minlength'].requiredLength }} characters
  </div>
</ng-container>

Show all errors on submit attempt:

// Mark all fields as touched when submitting invalid form
onSubmit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();  // Shows all validation errors
    return;
  }
  // Process form submission
}
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input formControlName="email" />
  <!-- Errors show after submit attempt (touched) OR after user interacts -->
  <div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
    <span *ngIf="form.get('email')?.errors?.['required']">Required</span>
    <span *ngIf="form.get('email')?.errors?.['email']">Invalid email</span>
  </div>
  <button type="submit">Submit</button>
</form>

Fix 3: Write Custom Validators Correctly

A validator function must return ValidationErrors | null:

// WRONG — returns false for invalid (should return error object)
function noSpaces(control: AbstractControl): ValidationErrors | null {
  if (control.value.includes(' ')) {
    return false;  // ← Wrong — must return an object or null
  }
  return null;
}

// WRONG — returns undefined for valid (should return null)
function noSpaces(control: AbstractControl) {
  if (control.value.includes(' ')) {
    return { noSpaces: true };
  }
  // Returns undefined implicitly — should return null
}
// CORRECT — returns error object or null
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Simple validator
function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  if (control.value && /\s/.test(control.value)) {
    return { noSpaces: true };   // Key used in template: errors?.['noSpaces']
  }
  return null;   // null = valid
}

// Validator with parameter (factory function)
function minAgeValidator(minAge: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const age = parseInt(control.value, 10);
    if (isNaN(age) || age < minAge) {
      return { minAge: { required: minAge, actual: age } };
    }
    return null;
  };
}

// Cross-field validator (on the FormGroup, not FormControl)
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;

  if (password !== confirm) {
    return { passwordMismatch: true };
  }
  return null;
}

// Apply validators
this.form = this.fb.group({
  username: ['', [Validators.required, noSpacesValidator]],
  age: ['', [Validators.required, minAgeValidator(18)]],
  password: ['', Validators.required],
  confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });  // ← Group-level validator

Access group-level validation errors in the template:

<!-- Group-level errors are on the form, not individual controls -->
<div *ngIf="form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched">
  Passwords do not match
</div>

Fix 4: Write Async Validators Correctly

Async validators check values against an API (username availability, email uniqueness):

// WRONG — doesn't return Observable
function checkUsernameAvailability(control: AbstractControl) {
  this.userService.checkUsername(control.value).subscribe(isAvailable => {
    if (!isAvailable) {
      return { usernameTaken: true };  // ← Wrong: returned inside subscribe, not from the validator
    }
  });
  // Returns undefined — async validator must return Observable/Promise
}
// CORRECT — returns Observable<ValidationErrors | null>
import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class UsernameValidator {
  constructor(private userService: UserService) {}

  checkAvailability(): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      if (!control.value) return of(null);

      return of(control.value).pipe(
        debounceTime(400),         // Wait 400ms after last keystroke
        switchMap(username =>
          this.userService.checkUsername(username).pipe(
            map(isAvailable => isAvailable ? null : { usernameTaken: true }),
            catchError(() => of(null)),  // Don't block form on API error
          )
        ),
        first(),  // Complete after one emission
      );
    };
  }
}

// Apply async validator
this.form = this.fb.group({
  username: ['',
    [Validators.required, Validators.minLength(3)],  // Sync validators
    [this.usernameValidator.checkAvailability()],    // Async validators (3rd arg)
  ],
});

Show async validation state:

<input formControlName="username" />
<div *ngIf="form.get('username')?.pending">Checking availability...</div>
<div *ngIf="form.get('username')?.errors?.['usernameTaken']">Username is taken</div>

The pending state is true while the async validator is running.

Fix 5: Fix Template-Driven Form Validation

Template-Driven Forms use ngModel — access validation state through template reference variables:

<!-- WRONG — #email is a reference to the input element, not the NgModel -->
<input type="email" name="email" ngModel #email />
<div *ngIf="email.invalid">Invalid</div>
<!-- email.invalid doesn't exist on HTMLInputElement -->

<!-- CORRECT — assign the NgModel directive to the reference variable -->
<input type="email" name="email" ngModel #email="ngModel" required email />
<div *ngIf="email.invalid && email.touched">
  <span *ngIf="email.errors?.['required']">Email is required</span>
  <span *ngIf="email.errors?.['email']">Invalid email format</span>
</div>

Built-in HTML5 validators as Angular validators:

<!-- These HTML attributes are recognized as Angular validators when FormsModule is imported -->
<input name="username" ngModel required minlength="3" maxlength="20" />
<input name="age" ngModel type="number" min="18" max="100" />
<input name="email" ngModel type="email" />  <!-- 'email' type validates format -->
<input name="website" ngModel pattern="https?://.+" />

Disable native HTML5 validation (Angular replaces it):

<form ngForm novalidate (ngSubmit)="onSubmit(f)" #f="ngForm">
  <!-- 'novalidate' disables browser's native validation — Angular handles it -->
</form>

Fix 6: Disable Submit Button When Form Is Invalid

Prevent submission of an invalid form directly in the template:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <!-- Input fields -->
  <input formControlName="email" type="email" />

  <!-- Disable button while form is invalid OR while submitting -->
  <button
    type="submit"
    [disabled]="form.invalid || isSubmitting"
  >
    {{ isSubmitting ? 'Submitting...' : 'Submit' }}
  </button>
</form>
isSubmitting = false;

async onSubmit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    return;
  }

  this.isSubmitting = true;
  try {
    await this.service.submit(this.form.value);
    // Handle success
  } catch (err) {
    // Handle error
  } finally {
    this.isSubmitting = false;
  }
}

Fix 7: Reusable Form Error Component

Avoid repeating error display logic with a reusable component:

// shared/form-error/form-error.component.ts
@Component({
  selector: 'app-form-error',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ng-container *ngIf="control && control.invalid && (control.touched || showAll)">
      <p class="error" *ngIf="control.errors?.['required']">This field is required.</p>
      <p class="error" *ngIf="control.errors?.['email']">Enter a valid email address.</p>
      <p class="error" *ngIf="control.errors?.['minlength']">
        Minimum {{ control.errors?.['minlength'].requiredLength }} characters required.
      </p>
      <p class="error" *ngIf="control.errors?.['maxlength']">
        Maximum {{ control.errors?.['maxlength'].requiredLength }} characters allowed.
      </p>
      <p class="error" *ngIf="control.errors?.['pattern']">Invalid format.</p>
      <p class="error" *ngIf="control.errors?.['usernameTaken']">Username is already taken.</p>
      <p class="error" *ngIf="control.errors?.['passwordMismatch']">Passwords do not match.</p>
    </ng-container>
  `,
})
export class FormErrorComponent {
  @Input() control: AbstractControl | null = null;
  @Input() showAll = false;  // Show errors even if not touched
}
<!-- Usage -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input formControlName="email" />
  <app-form-error [control]="form.get('email')" />

  <input formControlName="username" />
  <app-form-error [control]="form.get('username')" />

  <button type="submit">Submit</button>
</form>

Still Not Working?

Check the FormControl status directly:

const emailControl = this.form.get('email');
console.log('value:', emailControl?.value);
console.log('valid:', emailControl?.valid);
console.log('errors:', emailControl?.errors);
console.log('touched:', emailControl?.touched);
console.log('dirty:', emailControl?.dirty);
console.log('status:', emailControl?.status);  // 'VALID', 'INVALID', 'PENDING', 'DISABLED'

updateOn option affects when validation runs:

// By default, validation runs on every value change (updateOn: 'change')
// 'blur' — runs only when the control loses focus
// 'submit' — runs only when the form is submitted
this.form = this.fb.group({
  email: ['', { validators: [Validators.email], updateOn: 'blur' }],
});

If updateOn: 'submit' is set on the group or a control, validators won’t run until form submission — the control shows as valid until then.

Angular DevTools — inspect the form state in the Component tab. Select the component, expand the form properties, and see all control states in real time.

Disabled controls are excluded from validation — if a FormControl is disabled (this.form.get('email')?.disable()), Angular removes it from the form’s value and skips its validators entirely. The control’s status becomes 'DISABLED', and form.value does not include it. Use form.getRawValue() to get all values including disabled controls.

Dynamic validators added with setValidators replace existing ones — calling control.setValidators(Validators.minLength(3)) removes the previously set Validators.required. To add validators without removing existing ones, pass all validators in an array:

const ctrl = this.form.get('email');
ctrl?.setValidators([Validators.required, Validators.email, Validators.minLength(5)]);
ctrl?.updateValueAndValidity();  // Must call this after setValidators

Cross-field validator not showing errors — group-level validators set errors on the FormGroup, not on individual controls. Check form.errors in the template, not form.get('fieldName')?.errors. If you need the error to appear on a specific control, call control.setErrors({ customError: true }) inside the validator instead of returning the error object.

For related Angular issues, see Fix: Angular Change Detection Not Working, Fix: Angular RxJS Memory Leak, Fix: Angular Pipe Not Working, and Fix: Angular NullInjectorError.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles