JOURNAL

Typed Reactive Forms at Scale — Checkout, Validation, and Multi-Step Wizards

Post H — Angular 21 typed reactive forms in depth: FormGroup<T>, nonNullable, cross-field validators, async validators for slug uniqueness, and managing multi-step checkout flow with Signals.

angular techlead typed forms

Read with AI

Choose content to copy and paste into your AI assistant. Nothing is sent automatically. CMS content is converted to Markdown; original Markdown is used when available.

Typed reactive forms were introduced in Angular 14 and most developers know they exist. Far fewer use them correctly. In an ecommerce context — checkout forms, address forms, payment flows — untyped forms are a runtime error waiting to happen.

This post covers the typed forms patterns that make checkout safe, testable, and maintainable.

Why Untyped Forms Are a Problem

// ❌ Old-style untyped form
const form = new FormGroup({
  email: new FormControl(''),
  address: new FormGroup({
    street: new FormControl(''),
    city: new FormControl(''),
  })
});

// TypeScript says this is fine — but it returns string | null
const email = form.value.email;         // type: string | null | undefined
const street = form.value.address?.street; // type: string | null | undefined

// This compiles with no error but will crash at runtime
const length = form.value.email.length; // ❌ possibly null!

Typed FormGroup<T> Pattern

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

// Define the form shape as an interface
interface ShippingFormControls {
  fullName:    FormControl<string>;
  street:      FormControl<string>;
  city:        FormControl<string>;
  country:     FormControl<string>;
  postalCode:  FormControl<string>;
  saveAddress: FormControl<boolean>;
}

// FormGroup with full type parameter
type ShippingForm = FormGroup<ShippingFormControls>;
@Component({...})
export class ShippingStepComponent {
  private fb = inject(NonNullableFormBuilder);

  // nonNullable ensures .value is always string, never string | null
  shippingForm: ShippingForm = this.fb.group({
    fullName:    ['', [Validators.required, Validators.minLength(3)]],
    street:      ['', Validators.required],
    city:        ['', Validators.required],
    country:     ['SA', Validators.required],
    postalCode:  ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
    saveAddress: [false],
  });

  // Fully typed — TypeScript knows this is string, not string | null
  get fullName(): string {
    return this.shippingForm.controls.fullName.value; // ← type: string ✅
  }
}

NonNullableFormBuilder — The Default Choice

// ❌ FormBuilder — values are string | null
const fb = inject(FormBuilder);
const form = fb.group({ name: [''] }); 
form.value.name // type: string | null

// ✅ NonNullableFormBuilder — values are always the declared type
const fb = inject(NonNullableFormBuilder);
const form = fb.group({ name: [''] });
form.value.name // type: string

Use NonNullableFormBuilder for all forms in TechShop. The only time you want nullable is when a field is truly optional AND you need to distinguish between “not filled” and “empty string” — rare in practice.

Cross-Field Validators

// Validator function — receives the FormGroup, returns ValidationErrors | null
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;

  return password === confirm ? null : { passwordMismatch: true };
}

// Apply at group level, not control level
this.fb.group({
  password:        ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });

Template to show the error:

@if (passwordForm.hasError('passwordMismatch') && passwordForm.touched) {
  <span class="error">Passwords do not match</span>
}

Date Range Validator (for promotions)

function dateRangeValidator(group: AbstractControl): ValidationErrors | null {
  const start = group.get('startDate')?.value as Date;
  const end = group.get('endDate')?.value as Date;

  if (!start || !end) return null;
  return start < end ? null : { invalidRange: { start, end } };
}

Async Validators — Slug Uniqueness Check

// Async validator — returns Observable<ValidationErrors | null>
function slugUniqueValidator(api: ProductsService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    return timer(400).pipe(  // Debounce: wait 400ms before calling API
      switchMap(() =>
        api.checkSlugAvailability(control.value as string).pipe(
          map(isAvailable => isAvailable ? null : { slugTaken: true }),
          catchError(() => of(null))  // Network error → don't block form
        )
      )
    );
  };
}

// Usage
this.fb.group({
  slug: ['', {
    validators: [Validators.required, Validators.pattern(/^[a-z0-9-]+$/)],
    asyncValidators: [slugUniqueValidator(inject(ProductsService))],
    updateOn: 'blur'  // Only validate when user leaves the field
  }]
});

Multi-Step Checkout with Signals

The checkout wizard needs to track: current step, which steps are complete, and data from each step.

// libs/checkout/data-access/src/lib/checkout.store.ts
@Injectable({ providedIn: 'root' })
export class CheckoutStore {
  // State
  private _currentStep = signal<CheckoutStep>('shipping');
  private _shippingData = signal<ShippingData | null>(null);
  private _paymentData = signal<PaymentData | null>(null);

  // Selectors (public readonly)
  currentStep = this._currentStep.asReadonly();
  shippingData = this._shippingData.asReadonly();
  paymentData = this._paymentData.asReadonly();

  completedSteps = computed<CheckoutStep[]>(() => {
    const steps: CheckoutStep[] = [];
    if (this._shippingData()) steps.push('shipping');
    if (this._paymentData()) steps.push('payment');
    return steps;
  });

  canProceedToPayment = computed(() => this._shippingData() !== null);
  canProceedToReview = computed(() =>
    this._shippingData() !== null && this._paymentData() !== null
  );

  // Actions
  submitShipping(data: ShippingData) {
    this._shippingData.set(data);
    this._currentStep.set('payment');
  }

  submitPayment(data: PaymentData) {
    this._paymentData.set(data);
    this._currentStep.set('review');
  }

  goBack() {
    const prev: Record<CheckoutStep, CheckoutStep> = {
      payment: 'shipping',
      review: 'payment',
      shipping: 'shipping',
    };
    this._currentStep.update(current => prev[current]);
  }

  reset() {
    this._currentStep.set('shipping');
    this._shippingData.set(null);
    this._paymentData.set(null);
  }
}

type CheckoutStep = 'shipping' | 'payment' | 'review';

Checkout page:

<!-- checkout.component.html -->
@switch (store.currentStep()) {
  @case ('shipping') {
    <app-shipping-step (completed)="store.submitShipping($event)" />
  }
  @case ('payment') {
    <app-payment-step
      (completed)="store.submitPayment($event)"
      (back)="store.goBack()" />
  }
  @case ('review') {
    <app-order-review
      [shipping]="store.shippingData()!"
      [payment]="store.paymentData()!"
      (back)="store.goBack()"
      (confirm)="placeOrder()" />
  }
}

<app-checkout-progress
  [currentStep]="store.currentStep()"
  [completedSteps]="store.completedSteps()" />

Displaying Validation Errors Consistently

Create a shared utility component for error messages:

// libs/shared/ui/src/lib/form-error/form-error.component.ts
@Component({
  selector: 'app-form-error',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (control().invalid && (control().dirty || control().touched)) {
      @if (control().hasError('required')) {
        <span class="error">This field is required</span>
      }
      @if (control().hasError('minlength')) {
        <span class="error">
          Minimum {{ control().getError('minlength').requiredLength }} characters
        </span>
      }
      @if (control().hasError('pattern')) {
        <span class="error">{{ patternMessage() }}</span>
      }
      @if (control().hasError('slugTaken')) {
        <span class="error">This URL slug is already taken</span>
      }
    }
  `
})
export class FormErrorComponent {
  control = input.required<AbstractControl>();
  patternMessage = input('Invalid format');
}

Usage:

<input type="text" [formControl]="shippingForm.controls.fullName" />
<app-form-error [control]="shippingForm.controls.fullName" />

References


Part of the Angular Tech Lead Series — Back to main series overview

Discussion

Comments are reviewed before publication. Your email is kept private.

Add a comment

Name and email are required. Do not include confidential information.

Privacy & data

Loading anti-spam verification…

← Back to allĐọc tiếng Việt