type ApiResponse<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: Error };
function handleResponse(response: ApiResponse<string>) {
switch (response.status) {
case 'success':
// TS știe sigur că data există și e string
console.log(response.data.toUpperCase());
break;
case 'error':
// TS știe sigur că avem obiectul error
console.error(response.error.message);
break;
}
}Problema de la care am plecat
Acum vreo 4 ani, lucram la un dashboard pentru o clinică medicală unde aveam un flow complex de programări. Tipic developerului grăbit, am definit starea paginii ca o singură interfață mare, plină de câmpuri opționale. Aveam ceva de genul: isLoading, error, appointments, selectedAppointment.
Ce a urmat a fost un coșmar de mentenanță. Aveam cazuri în care isLoading era false, dar appointments era undefined, deși teoretic trebuia să am date. Codul era plin de verificări de tipul if (data && !loading && !error) și assertions forțate cu operatorul ! (non-null assertion). Practic, TypeScript devenise un dușman pe care îl mințeam doar ca să scap de erorile de compilare.
Ce este un Discriminated Union și cum ne salvează
Ideea e simplă: în loc să ai un singur obiect mamut cu proprietăți care pot sau nu să existe, împarți stările în tipuri complet separate. Singura lor legătură este un câmp comun, numit de obicei type, status sau kind, care are o valoare literală (un string exact).
TypeScript este extrem de inteligent aici. Când faci un switch sau un if pe acel câmp discriminant, compilatorul face un "narrowing" (îngustare de tip). Știe exact ce alte proprietăți sunt disponibile în acel bloc de cod și îți dă autocomplete instant.
Am aplicat schema asta la API responses și reducer actions pe un proiect cu 8k useri activi și am redus bug-urile de UI din stările de loading/error cu aproape 30% la următorul release.
Unde strălucește pattern-ul ăsta
În primul rând, la stările de UI (State Machines). O pagină poate fi în starea Idle, Loading, Success sau Error. În starea loading nu ai de ce să ai un obiect error rătăcit pe acolo.
În al doilea rând, la răspunsurile de API. Când ai un endpoint care poate returna fie datele de succes, fie o listă de erori de validare, fie o eroare de sistem, tipizarea devine extrem de curată.
În al treilea rând, la Reducer Actions (Redux sau useReducer). În loc de un tip generic pentru action.payload, fiecare action.type își definește propriul payload strict.
Trade-off-urile de care nu prea se vorbește
Să fim sinceri, nimic nu e gratis în meseria asta. Cel mai mare dezavantaj e că scrii mai mult cod la început. Trebuie să definești 4-5 interfețe în loc de una singură și grasă.
De asemenea, dacă folosești librării de UI care se așteaptă la o structură plată și simplă, s-ar putea să fii nevoit să faci mapări suplimentare înainte de randare. Pentru o aplicație mică, de weekend, e clar overkill. Dar la proiecte medii și mari, unde ai logică de business serioasă, îți salvează nopțile și weekendurile.
Voi cum gestionați stările complexe în React sau Angular? Tot cu câmpuri opționale sau ați trecut complet pe unions?