0 Tage Serie
javascript / expert
Snippet

Mehrstufige Angular-Wizards als diskriminierte Union-Zustandsmaschine modellieren

Eine häufige Quelle von Laufzeitfehlern in mehrstufigen Angular-Formularen ist ein einziges flaches Interface, in dem jedes Feld optional ist (cardToken?: string, orderId?: string) — das lässt eine Komponente problemlos kompilieren, obwohl sie ein Feld liest, das im aktuellen Schritt logisch noch gar nicht existieren kann, und liefert stillschweigend undefined statt eines Kompilierfehlers. Wird der Wizard als TypeScript-Discriminated-Union modelliert, die über ein step-Literal unterschieden wird, werden ungültige Zustände unrepräsentierbar: Der Typprüfer verweigert es submitAccount, einen 'confirm'-Zustand ohne ein agreed-Feld zu konstruieren, und jede Komponente, die state() liest, muss vor dem Zugriff auf schrittspezifische Felder über .step einengen — das fängt eine ganze Klasse von Null-Reference-Bugs schon beim Build statt erst in Produktion ab. Dieses Muster skaliert sauber auf signal-basierte Stores, ohne für mäßig komplexe Abläufe eine vollständige externe State-Machine-Bibliothek wie XState zu benötigen.

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
Erklärung
1
type WizardState = | { step: 'account'; email: string } | ...
Definiert jeden Wizard-Schritt als eigene Form, die nur das unterscheidende Feld 'step' gemeinsam hat.
2
if (s.step !== 'billing') { throw new Error(...); }
Schützt vor einem Übergangsaufruf außer der Reihe und macht aus einem Logikfehler eine explizite, fangbare Exception.
3
this.state.set({ ...s, step: 'confirm', cardToken, agreed: false });
Spreadet den eingeengten 'billing'-Zustand, von dem TypeScript nun weiß, dass er email und cardToken enthält, in das nächste Union-Mitglied.
4
if (s.step !== 'confirm' || !s.agreed) { throw new Error(...); }
Kombiniert eine Schritt-Prüfung mit einer Geschäftsregel-Prüfung und verhindert eine Bestellbestätigung vor erteilter Zustimmung.