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 SeriesBack to main series overview

Export for reading

Comments