type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; receivedAt: number }
| { status: 'error'; error: Error };
function renderUI(state: RequestState<string[]>) {
switch (state.status) {
case 'idle':
return 'Apasă butonul pentru a încărca.';
case 'loading':
return 'Se încarcă datele...';
case 'success':
// TS știe 100% că state.data există aici
return `Avem ${state.data.length} rezultate.`;
case 'error':
// TS știe că state.error există doar aici
return `Eroare: ${state.error.message}`;
default: {
// Asigură verificarea exhaustivă la compile-time
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}
}Dacă te uiți în codebase-ul tău și vezi interfețe pline de flag-uri opționale precum isLoading?: boolean, error?: string și data?: User, ai o bombă cu ceas. Ai creat stări imposibile pe care TypeScript te lasă să le compilezi fără să clipească.
Am pățit-o acum vreo trei ani pe un checkout care procesa în jur de 14.000 de comenzi pe lună. Aveam un hook scris pe grabă unde se puteau întâlni simultan isLoading: true și error: 'Card declined'. Rezultatul în UI? Un spinner infinit desenat exact peste butonul de reîncercare, utilizatorul credea că aplicația s-a blocat și dădea refresh, duplicând comanda în backend. O mizerie cauzată exclusiv de un model de date prost.
Soluția pe care o aplic religios de atunci sunt Discriminated Unions (sau tagged unions).
Ce rezolvă de fapt pattern-ul ăsta
Ideea din spate e banală: în loc să ai un singur obiect uriaș cu toate proprietățile posibile marcate ca opționale, spargi starea în tipuri distincte care au în comun o proprietate literală — de obicei numită status, type sau kind.
TypeScript e suficient de deștept încât, în momentul în care verifici valoarea acelei proprietăți într-un switch sau if, restrânge automat tipul obiectului (type narrowing).
Nu mai poți accesa data dacă ești în starea 'loading', pentru că acel câmp pur și simplu nu există pe tipul respectiv. Dacă încerci, codul nu compilează. Nu ai cum să împingi în producție o randare care încearcă să citească proprietăți inexistente.
Unde strălucește în producție
Folosesc discriminated unions în trei locuri mari:
- Răspunsuri de API: În loc să returnez
{ data: null, error: '...' }, returnez{ success: true, data: T }sau{ success: false, code: string, message: string }. Consumatorul API-ului este obligat de compilator să verificeres.successînainte să atingăres.data. - Reduceri de stare (State machines): Fie că e vorba de Redux, Zustand sau un banal
useReducerîn React. Când legi starea unui wizard complex de un union cu 4 stări distincte, e imposibil să sari pași sau să transmiți date incomplete. - Event handling: Pentru acțiuni de analytics sau mesaje pe WebSockets unde payload-ul depinde direct de tipul evenimentului.
Trade-off-ul sincer
Nu e totul lapte și miere, altfel toată lumea ar scrie doar așa.
Principalul dezavantaj este volumul de cod: trebuie să definești mai multe interfețe, să scrii mai mult boilerplate și să te obișnuiești să gândești exhaustiv. Dacă ai o pagină simplă de prezentare cu un formular amărât de newsletter, să trântești un state machine complet e overengineering curat.
Al doilea hop apare când backend-ul nu este scris de tine și primești un JSON plat, incoerent. În cazul ăla, trebuie să scrii manual o funcție de mapare sau un parser cu Zod la intrare ca să transformi răspunsul haotic într-un union curat pe frontend. Merită efortul? În opinia mea, da, 100% din timp pe proiecte medii și mari.
Bonus: dacă folosești verificarea exhaustivă cu tipul never pe ramura de default, compilerul o să urle instant la tine în ziua în care adaugi o stare nouă în union și uiți să o tratezi în switch.
Voi cum abordați stările de fetch în frontend? Vă bazați pe librării gen TanStack Query să mascheze asta sau vă modelați explicit stările?