type ApiResponse<T> =
| { status: 'loading' }
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: Error };
function handleResponse(response: ApiResponse<string>) {
switch (response.status) {
case 'loading':
return 'Se încarcă...';
case 'success':
// TS știe sigur că 'data' și 'timestamp' există aici
return `Date: ${response.data.toUpperCase()} la ${response.timestamp}`;
case 'error':
// TS știe sigur că avem obiectul 'error'
return `Eroare: ${response.error.message}`;
}
}M-am lovit de prea multe ori de interfețe de tip "Frankenstein" în care toate proprietățile sunt opționale. Știi și tu genul: data?: T, error?: string, isLoading: boolean. E rețeta perfectă pentru bug-uri ascunse în producție și ecrane albe.
De ce urăsc "opționalul total"
Acum vreo trei ani, lucram la o aplicație de procesare plăți cu vreo 12.000 de useri activi zilnic. Aveam un state de checkout care arăta exact ca exemplul de mai sus. Ne trezeam des în producție cu loading-uri infinite pentru că cineva uitase să reseteze isLoading pe false când venea o eroare. TypeScript nu ne ajuta deloc pentru că, din punctul lui de vedere, toate combinațiile acelea de proprietăți opționale erau perfect valide.
Soluția a fost să trecem totul pe discriminated unions (sau tagged unions). Ideea e simplă: folosești o proprietate comună (de obicei numită type, status sau kind) care acționează ca un "discriminator". TypeScript e destul de deștept să facă control flow analysis și să îngusteze tipul exact în funcție de acea valoare.
Cum arată în practică
Când muți starea într-un union, elimini complet stările imposibile. Nu mai poți avea și data și error în același timp, iar compilatorul te trage de mână dacă încerci să accesezi data înainte să verifici dacă statusul e 'success'.
În exemplul de cod atașat, poți vedea cum TypeScript știe exact ce proprietăți sunt disponibile în fiecare ramură a switch-ului. Dacă încerci să accesezi response.data în afara blocului success, codul pur și simplu nu va compila. Asta înseamnă zero erori de tip "Cannot read property of undefined" în producție pe bucata asta.
Reducer Actions și State Machines
Pe lângă răspunsurile de API, folosesc pattern-ul ăsta masiv în React/Redux sau când implementez mici mașini de stări. De exemplu, la un wizard de onboarding cu 4 pași. Fiecare pas are datele lui specifice. În loc să am un singur obiect uriaș cu 20 de câmpuri opționale, fac un union de tipuri pentru fiecare pas în parte.
În reducer, când fac switch pe action.type, TypeScript îmi oferă autocomplete doar pentru payload-ul specific acelei acțiuni. E o plăcere să scrii cod așa.
Trade-off-ul sincer: când devine un chin
Să fim sinceri, pattern-ul ăsta nu e glonțul de argint. Vine la pachet cu un cost destul de mare de boilerplate. Dacă ai o uniune cu mai mult de 10-15 tipuri, devine extrem de obositor să scrii switch-uri uriașe. Refactoring-ul devine și el greoi: dacă schimbi structura unui tip din uniune, trebuie să vânezi toate locurile unde făceai pattern matching.
De asemenea, dacă ai nevoie de stări hibride (de exemplu, vrei să arăți datele vechi pe fundal în timp ce faci re-fetch), discriminated unions te forțează să fii foarte explicit, ceea ce poate duce la cod destul de duplicat.
Voi cum gestionați stările astea complexe în TypeScript? Mergeți pe unions clare sau lăsați lucrurile mai libere cu opționale?