type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string };
function renderUI(state: AsyncState<string[]>) {
switch (state.status) {
case 'idle':
return 'Așteptare...';
case 'loading':
return 'Se încarcă datele...';
case 'success':
// Aici TS știe sigur că state are proprietatea data
return state.data.map(item => `<li>${item}</li>`);
case 'error':
return `Eroare: ${state.message}`;
}
}Am pierdut ore întregi debug-uind stări imposibile în aplicații React până când m-am prins că problema nu e la logică, ci la cum defineam datele. Dacă ai și tu obiecte cu isLoading, isError și data aruncate la grămadă, probabil ai simțit deja durerea de a avea simultan loading și eroare pe ecran.
La un proiect de e-commerce cu vreo 10k useri activi, aveam un flux de checkout care o lua razna la fiecare pas de validare. Aveam vreo 4 flag-uri de tip boolean și, la un moment dat, codul devenise un spaghetti de if-else-uri imposibil de urmărit. Am decis să refactorizez totul folosind Discriminated Unions și am eliminat cam 30% din codul de validare, plus că am scăpat definitiv de bug-urile de tip „stare imposibilă”.
Problema cu flag-urile booleene
Majoritatea facem asta la început: definim un obiect de stare cu loading: boolean, error: string | null și data: T | null. Pare logic, dar TypeScript nu știe că, dacă loading e true, atunci data ar trebui să fie null. Pentru compilator, toate combinațiile sunt valide, inclusiv cele care n-au sens în realitate.
Am pățit de multe ori să uit să resetez eroarea când porneam un request nou. Rezultatul? Userul vedea spinner-ul de loading, dar sub el încă apărea mesajul de eroare de la încercarea precedentă. E genul de bug vizual care face aplicația să pară ieftină și nefinisată.
Cum funcționează Discriminated Unions
Secretul stă într-o singură proprietate comună, de obicei numită type, status sau kind. Această proprietate servește ca „discriminant”. Când faci un switch sau un if pe ea, TypeScript restrânge automat tipul obiectului la varianta specifică acelei stări.
În loc de un singur obiect mare cu proprietăți opționale, definim mai multe tipuri mici și le unim. Dacă suntem în starea 'success', știm sigur că avem datele. Dacă suntem în 'error', avem mesajul de eroare. Nu mai există „poate”.
Exhaustiveness checking: Plasa de siguranță
Un beneficiu uriaș pe care l-am observat la reducer-ii din Redux sau useReducer e posibilitatea de a face „exhaustiveness checking”. Dacă folosești un switch și adaugi un nou tip de acțiune în union, dar uiți să-l gestionezi în reducer, TypeScript îți va da eroare la compilare dacă folosești tipul never pe ramura default.
Am avut cazul unui modul de plăți unde am adăugat o stare nouă, 'pending_verification'. Grație acestui pattern, compiler-ul mi-a arătat imediat cele 4 locuri din aplicație unde trebuia să gestionez noua stare. Fără asta, aș fi aflat de problemă probabil direct din log-urile de eroare ale clienților.
Trade-off-uri sincere
Nu e totul lapte și miere. Trebuie să fii pregătit să scrii mai mult boilerplate la început. Definirea interfețelor pentru fiecare stare în parte consumă timp și poate părea redundant pentru ecrane foarte simple. De asemenea, dacă lucrezi cu librării care nu sunt scrise în TS sau care au tipuri proaste, va trebui să faci manual maparea către union-ul tău.
Merge excelent pentru fluxuri complexe de date și state machines, dar e probabil overkill pentru un simplu input de search care doar filtrează o listă locală. Totuși, pentru orice request de rețea, mi se pare sfânt.
Voi cum gestionați stările asincrone? Mai mergeți pe varianta cu booleeni separați sau ați trecut la uniuni?