Template-Driven Validation — Built-In Validators and Error Display

Template-driven validation works by adding HTML validation attributes to form inputs. Angular’s form directives (NgModel, NgForm) wrap these attributes and expose their state as form control properties. The control’s errors object contains one key per failed validator, each with the validator’s error data — allowing precise error messages. CSS classes (ng-valid, ng-invalid, ng-touched) are automatically applied, enabling CSS-based visual validation feedback.

Validation and Error Display

@Component({
  standalone:  true,
  imports:    [FormsModule],
  template: `
    <form #postForm='ngForm' (ngSubmit)="onSubmit()" novalidate>

      <!-- Required + length validation ────────────────────────────────── -->
      <div class="field">
        <label>Title *</label>
        <input name="title"
               type="text"
               [(ngModel)]="draft.title"
               required
               minlength="5"
               maxlength="200"
               #titleControl="ngModel"
               [class.is-invalid]="titleControl.invalid && titleControl.touched">

        <!-- Error messages — only one at a time ─────────────────────────── -->
        @if (titleControl.invalid && titleControl.touched) {
          @if (titleControl.errors?.['required']) {
            <span class="error">Title is required.</span>
          } @else if (titleControl.errors?.['minlength']) {
            <span class="error">
              Title must be at least
              {{ titleControl.errors?.['minlength'].requiredLength }} characters.
              Currently: {{ titleControl.errors?.['minlength'].actualLength }}.
            </span>
          } @else if (titleControl.errors?.['maxlength']) {
            <span class="error">Title cannot exceed 200 characters.</span>
          }
        }

        <!-- Character count indicator ────────────────────────────────── -->
        <span class="char-count"
              [class.warning]="draft.title.length > 180">
          {{ draft.title.length }}/200
        </span>
      </div>

      <!-- Pattern validation ──────────────────────────────────────────── -->
      <div class="field">
        <label>Slug *</label>
        <input name="slug"
               type="text"
               [(ngModel)]="draft.slug"
               required
               pattern="^[a-z0-9-]+$"
               #slugControl="ngModel">

        @if (slugControl.invalid && slugControl.touched) {
          @if (slugControl.errors?.['required']) {
            <span class="error">Slug is required.</span>
          } @else if (slugControl.errors?.['pattern']) {
            <span class="error">Slug can only contain lowercase letters, numbers, and hyphens.</span>
          }
        }
      </div>

      <!-- Submit — mark all as touched to show all errors at once ─────── -->
      <button type="submit"
              (click)="postForm.form.markAllAsTouched()">
        Create Post
      </button>
    </form>
  `,
})
export class PostCreateSimpleComponent {
  draft = { title: '', slug: '' };

  onSubmit(): void {
    // Form is guaranteed valid at this point (ngSubmit only fires if valid)
    console.log('Submitting:', this.draft);
  }
}
Note: The errors object on a control contains a key for each failing validator. For minlength, the value is { requiredLength: 5, actualLength: 3 } — you can display both numbers in the error message. For required, the value is true. For pattern, the value contains the pattern and actual value. These structured error objects allow precise, informative error messages rather than generic “field is invalid” messages.
Tip: Call form.form.markAllAsTouched() when the user clicks the submit button (before the submit handler runs). This marks all controls as touched, triggering all validation error messages to appear even for fields the user never interacted with. Without this, a user who skips a required field and clicks Submit sees no errors (the field is untouched) and wonders why nothing happened. This pattern is the standard for revealing all errors on submit attempt.
Warning: The pattern attribute uses JavaScript’s RegExp matching with implicit anchors. The validator checks if the full value matches the pattern — it is equivalent to new RegExp('^' + pattern + '$'). Angular automatically anchors the pattern. Do not add ^ and $ in the pattern attribute unless you intend to create a different regex. Check your regex against test cases because incorrect patterns silently pass when the input happens to match partially.

CSS Validation Styling

// ── Global styles.scss — style based on Angular form state classes ─────────
// input.ng-touched.ng-invalid {
//   border-color: #dc2626;   /* red */
//   background: #fff5f5;
// }
//
// input.ng-touched.ng-valid {
//   border-color: #16a34a;   /* green */
// }
//
// input.ng-untouched {
//   border-color: #d1d5db;   /* neutral gray */
// }

Common Mistakes

Mistake 1 — Not calling markAllAsTouched() on submit (required errors invisible on skip)

❌ Wrong — user skips a required field and clicks Submit; no errors shown; submit appears broken.

✅ Correct — call form.form.markAllAsTouched() on the submit button click to reveal all errors.

Mistake 2 — Reading errors without optional chaining (runtime error when control is valid)

❌ Wrong — titleControl.errors.required; when valid, errors is null; null.required throws.

✅ Correct — use optional chaining: titleControl.errors?.['required'].

🧠 Test Yourself

A user fills in a required field with “ab” when minlength is 5. Which state properties are true on the control?