Angular course · Module 8: Forms

Typed Forms (Angular 14+)

1 min read ·
In this lesson2

Typed Forms provide full type safety - like the precise strokes of a calligraphy master!

Defining Types

import { FormControl, FormGroup, NonNullableFormBuilder } from '@angular/forms';

// Form interface
interface SamuraiForm {
  name: FormControl<string>;
  level: FormControl<number>;
  clan: FormControl<string>;
  skills: FormControl<string[]>;
}

@Component({...})
export class TypedFormComponent {
  // NonNullableFormBuilder - controls cannot be null
  private fb = inject(NonNullableFormBuilder);

  samuraiForm: FormGroup<SamuraiForm> = this.fb.group({
    name: ['', Validators.required],
    level: [1, Validators.min(1)],
    clan: ['Ronin'],
    skills: [['Kendo']]
  });

  onSubmit(): void {
    // value is fully typed!
    const formValue = this.samuraiForm.getRawValue();
    // Type: { name: string, level: number, clan: string, skills: string[] }

    console.log(formValue.name);  // string, not string | null
  }
}

FormControl vs FormGroup vs FormArray

import { FormControl, FormGroup, FormArray } from '@angular/forms';

// FormControl - single value
const nameControl = new FormControl<string>('');

// FormGroup - object with fields
const samuraiGroup = new FormGroup({
  name: new FormControl(''),
  level: new FormControl(1)
});

// FormArray - array of controls
const skillsArray = new FormArray([
  new FormControl('Kendo'),
  new FormControl('Iaido')
]);

// Accessing values
console.log(nameControl.value);       // string
console.log(samuraiGroup.value);      // { name: string, level: number }
console.log(skillsArray.value);       // string[]
Code for this lesson: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
import { Observable, of, delay, map } from 'rxjs';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <div class="registration-dojo">
      <h1>Registration at the Samurai Academy</h1>

      <form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">

        <div class="form-field">
          <label>Warrior name *</label>
          <input formControlName="username" placeholder="Unique name...">

          @if (registrationForm.get('username')?.pending) {
            <span class="checking">Checking availability...</span>
          }

          @if (registrationForm.get('username')?.touched && registrationForm.get('username')?.errors) {
            <div class="error-box">
              @if (registrationForm.get('username')?.errors?.['required']) {
                <span>Name is required</span>
              }
              @if (registrationForm.get('username')?.errors?.['minlength']) {
                <span>Minimum 4 characters</span>
              }
              @if (registrationForm.get('username')?.errors?.['usernameTaken']) {
                <span>This name is already taken!</span>
              }
            </div>
          }

          @if (registrationForm.get('username')?.valid) {
            <span class="valid-mark">Name available</span>
          }
        </div>

        <div class="form-field">
          <label>Email *</label>
          <input type="email" formControlName="email" placeholder="your@email.com">

          @if (registrationForm.get('email')?.touched && registrationForm.get('email')?.errors) {
            <div class="error-box">
              @if (registrationForm.get('email')?.errors?.['required']) {
                <span>Email is required</span>
              }
              @if (registrationForm.get('email')?.errors?.['email']) {
                <span>Invalid email format</span>
              }
            </div>
          }
        </div>

        <div class="form-field" formGroupName="passwords">
          <label>Password *</label>
          <input type="password" formControlName="password" placeholder="Strong password...">

          @if (registrationForm.get('passwords.password')?.touched) {
            <div class="password-strength">
              <div class="strength-bar" [style.width.%]="passwordStrength()">
              </div>
              <span>Strength: {{ passwordStrengthLabel() }}</span>
            </div>
          }

          <label>Repeat password *</label>
          <input type="password" formControlName="confirmPassword" placeholder="Repeat password...">

          @if (registrationForm.get('passwords')?.errors?.['passwordMismatch'] &&
               registrationForm.get('passwords.confirmPassword')?.touched) {
            <div class="error-box">
              <span>Passwords do not match!</span>
            </div>
          }
        </div>

        <div class="form-field">
          <label class="checkbox-label">
            <input type="checkbox" formControlName="acceptTerms">
            I accept the Bushido code (terms of service) *
          </label>

          @if (registrationForm.get('acceptTerms')?.touched &&
               registrationForm.get('acceptTerms')?.errors?.['requiredTrue']) {
            <div class="error-box">
              <span>You must accept the terms of service</span>
            </div>
          }
        </div>

        <div class="form-summary">
          <h3>Form status:</h3>
          <ul>
            <li [class.valid]="registrationForm.get('username')?.valid">
              Name: {{ registrationForm.get('username')?.valid ? 'OK' : 'X' }}
            </li>
            <li [class.valid]="registrationForm.get('email')?.valid">
              Email: {{ registrationForm.get('email')?.valid ? 'OK' : 'X' }}
            </li>
            <li [class.valid]="registrationForm.get('passwords')?.valid">
              Passwords: {{ registrationForm.get('passwords')?.valid ? 'OK' : 'X' }}
            </li>
            <li [class.valid]="registrationForm.get('acceptTerms')?.valid">
              Terms: {{ registrationForm.get('acceptTerms')?.valid ? 'OK' : 'X' }}
            </li>
          </ul>
        </div>

        <button type="submit" [disabled]="registrationForm.invalid || registrationForm.pending">
          {{ registrationForm.pending ? '⏳ Checking...' : 'Join the Academy' }}
        </button>
      </form>

      @if (registered()) {
        <div class="success-message">
          <h2>Welcome to the Academy, {{ registrationForm.get('username')?.value }}!</h2>
          <p>Your samurai adventure begins!</p>
        </div>
      }
    </div>
  `,
  styles: [`
    .registration-dojo {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #e94560;
    }

    form {
      max-width: 500px;
      margin: 0 auto;
      background: rgba(255,255,255,0.1);
      padding: 30px;
      border-radius: 15px;
    }

    .form-field {
      margin-bottom: 20px;
    }

    .form-field label {
      display: block;
      margin-bottom: 8px;
      color: #e94560;
      font-weight: bold;
    }

    .form-field input[type="text"],
    .form-field input[type="email"],
    .form-field input[type="password"] {
      width: 100%;
      padding: 12px;
      border: 2px solid #0f3460;
      border-radius: 8px;
      background: #0f3460;
      color: white;
      font-size: 16px;
      box-sizing: border-box;
      margin-bottom: 5px;
    }

    .form-field input:focus {
      border-color: #e94560;
      outline: none;
    }

    .form-field input.ng-valid.ng-touched {
      border-color: #28a745;
    }

    .form-field input.ng-invalid.ng-touched {
      border-color: #dc3545;
    }

    .checking {
      color: #ffc107;
      font-size: 0.9em;
    }

    .valid-mark {
      color: #28a745;
      font-size: 0.9em;
    }

    .error-box {
      background: rgba(220, 53, 69, 0.2);
      border: 1px solid #dc3545;
      border-radius: 5px;
      padding: 8px;
      margin-top: 5px;
    }

    .error-box span {
      display: block;
      color: #fca5a5;
      font-size: 0.85em;
    }

    .password-strength {
      margin: 10px 0;
    }

    .strength-bar {
      height: 8px;
      background: linear-gradient(90deg, #dc3545 0%, #ffc107 50%, #28a745 100%);
      border-radius: 4px;
      transition: width 0.3s;
    }

    .checkbox-label {
      display: flex;
      align-items: center;
      gap: 10px;
      cursor: pointer;
    }

    .checkbox-label input {
      width: 20px;
      height: 20px;
    }

    .form-summary {
      background: rgba(0,0,0,0.3);
      padding: 15px;
      border-radius: 8px;
      margin: 20px 0;
    }

    .form-summary h3 {
      margin-top: 0;
      color: #e94560;
    }

    .form-summary ul {
      list-style: none;
      padding: 0;
    }

    .form-summary li {
      padding: 5px 0;
      color: #888;
    }

    .form-summary li.valid {
      color: #28a745;
    }

    button[type="submit"] {
      width: 100%;
      padding: 15px;
      background: #e94560;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 18px;
      cursor: pointer;
      transition: background 0.3s;
    }

    button[type="submit"]:hover:not(:disabled) {
      background: #ff6b6b;
    }

    button[type="submit"]:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    .success-message {
      text-align: center;
      background: rgba(40, 167, 69, 0.2);
      border: 2px solid #28a745;
      border-radius: 15px;
      padding: 30px;
      margin-top: 20px;
      max-width: 500px;
      margin-left: auto;
      margin-right: auto;
    }

    .success-message h2 {
      color: #28a745;
    }
  `]
})
export class AppComponent {
  registrationForm: FormGroup;
  registered = signal(false);

  // Simulation of taken names
  private takenUsernames = ['musashi', 'kojiro', 'yagyu', 'admin', 'samurai'];

  constructor(private fb: FormBuilder) {
    this.registrationForm = this.fb.group({
      username: ['',
        [Validators.required, Validators.minLength(4)],
        [this.usernameAvailableValidator()]
      ],
      email: ['', [Validators.required, Validators.email]],
      passwords: this.fb.group({
        password: ['', [Validators.required, Validators.minLength(8)]],
        confirmPassword: ['', Validators.required]
      }, { validators: this.passwordMatchValidator }),
      acceptTerms: [false, Validators.requiredTrue]
    });
  }

  // Async validator - checks if name is available
  usernameAvailableValidator(): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      if (!control.value) {
        return of(null);
      }

      // Simulating an API request
      return of(control.value).pipe(
        delay(1000),
        map(username => {
          const isTaken = this.takenUsernames.includes(username.toLowerCase());
          return isTaken ? { usernameTaken: true } : null;
        })
      );
    };
  }

  // Cross-field validator
  passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
    const password = control.get('password')?.value;
    const confirmPassword = control.get('confirmPassword')?.value;

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

  passwordStrength(): number {
    const password = this.registrationForm.get('passwords.password')?.value || '';
    let strength = 0;

    if (password.length >= 8) strength += 25;
    if (password.length >= 12) strength += 15;
    if (/[A-Z]/.test(password)) strength += 20;
    if (/[a-z]/.test(password)) strength += 10;
    if (/[0-9]/.test(password)) strength += 15;
    if (/[^A-Za-z0-9]/.test(password)) strength += 15;

    return Math.min(strength, 100);
  }

  passwordStrengthLabel(): string {
    const strength = this.passwordStrength();
    if (strength < 30) return 'Weak';
    if (strength < 60) return 'Medium';
    if (strength < 80) return 'Good';
    return 'Very strong';
  }

  onSubmit(): void {
    if (this.registrationForm.valid) {
      this.registered.set(true);
    }
  }
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What do Typed Forms (Angular 14+) provide?

  2. 2. What does using NonNullableFormBuilder guarantee?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Order the form building blocks from the smallest to the largest scope (from a single value to an array of groups).

  • Code editor

    Complete the template that displays validation errors only when the field has been touched.

  • Click in order

    Build the control definition with validators: ['', [Validators.required, Validators.email]]. Click the elements in order.

Useful articles