0 day streak
javascript / expert
Snippet

Modeling Multi-Step Angular Wizards as a Discriminated Union State Machine

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.

snippet.js
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 });
}
}
angular
Breakdown
1
type WizardState = | { step: 'account'; email: string } | ...
Defines each wizard step as a distinct shape sharing only the discriminant 'step' field.
2
if (s.step !== 'billing') { throw new Error(...); }
Guards against calling a transition out of sequence, turning a logic error into an explicit, catchable exception.
3
this.state.set({ ...s, step: 'confirm', cardToken, agreed: false });
Spreads the narrowed 'billing' state, which TypeScript now knows includes email and cardToken, into the next union member.
4
if (s.step !== 'confirm' || !s.agreed) { throw new Error(...); }
Combines a step check with a business-rule check, preventing order confirmation before consent is given.