A common source of runtime bugs in multi-step Angular forms is a single flat interface with every field optional (cardToken?: string, orderId?: string), which lets a component compile fine while reading a field that logically cannot exist yet at the current step, silently yielding undefined instead of a compile error. Modeling the wizard as a TypeScript discriminated union keyed on a step literal makes illegal states unrepresentable: the type checker refuses to let submitAccount construct a 'confirm' state without an agreed field, and any component reading state() must narrow on .step before accessing step-specific fields, catching an entire class of null-reference bugs at build time instead of in production. This pattern scales cleanly to signal-based stores without needing a full external state machine library like XState for moderately complex flows.
type WizardState =| { step: 'account'; email: string }| { step: 'billing'; email: string; cardToken: string }| { step: 'confirm'; email: string; cardToken: string; agreed: boolean }| { step: 'done'; orderId: string };import { Injectable, signal } from '@angular/core';@Injectable({ providedIn: 'root' })export class CheckoutWizardStore {private readonly state = signal<WizardState>({step: 'account',email: '',});readonly current = this.state.asReadonly();submitAccount(email: string): void {// The compiler forbids constructing a 'billing' state without// the fields that 'account' never had, e.g. cardToken.this.state.set({ step: 'billing', email, cardToken: '' });}submitBilling(cardToken: string): void {const s = this.state();if (s.step !== 'billing') {throw new Error(`Cannot submit billing from step '${s.step}'`);}this.state.set({ ...s, step: 'confirm', cardToken, agreed: false });}confirmOrder(orderId: string): void {const s = this.state();if (s.step !== 'confirm' || !s.agreed) {throw new Error('Order cannot be confirmed yet');}this.state.set({ step: 'done', orderId });}}