Nie, nie mam na myśli żadnych organizacji politycznych. Unie dyskryminowane, bo o nich mowa, to jeden z moich ulubionych mechanizmów, jakie oddaje w ręce programisty TypeScript. Dzięki nim możemy wdrażać reguły biznesowe aplikacji za pomocą typów, zrzucając na barki kompilatora egzekwowanie ich. W tym wpisie pokażę, jak w prosty sposób to zrobić, jednocześnie uszczelniając swój kod.
Wyobraźmy sobie sytuację, w której mamy różne dane, w zależności od statusu. W elektronicznym systemie uczelnianym, prosimy o przesunięcie obrony pracy magisterskiej. Jeśli nasz wniosek zostanie przyjęty, backend wyśle nam informację o tym, że wszystko poszło OK. Status to Approved i otrzymujemy nową datę obrony. Jeśli jednak uczelnia odrzuciła nasze podanie, to całkiem normalne, że chcielibyśmy wiedzieć, co było tego przyczyną wraz z poprzednią datą egzaminu.
Rozpiszmy sobie to. Najprostszy sposób, jest, oczywiście, następujący:
enum SubmissionStatus {Approved = "Approved",Rejected = "Rejected",Pending = "Pending",}type Submission = {status: SubmissionStatus;reason?: string;examDate?: Date;};
Jak widać, rozważamy tutaj wszystkie opcje na raz, zakładając, że czasem pojawi się reason, a czasem też może zabraknąć examDate. Jedyna stała to status. Spróbujmy zrobić z tego przypadku coś znacznie bardziej czytelnego i - przede wszystkim - bezpieczniejszego.
Zastanówmy się najpierw, jak pracować z takim kodem. Nie jest łatwo. Operując na obiekcie o typie Submission, musimy za każdym razem sprawdzać, jaki jest jego status, porównując z enumem, a w dodatku musimy sprawdzać, czy opcjonalne pole reason lub examDate są wypełnione:
const popUpNewDate = (submission: Submission) => {if (submission.status === SubmissionStatus.Approved && submission.examDate) {alert(submission.examDate.toString());}};
W powyższym przypadku trywialnie wyrzucamy alertem nową datę, jeśli wszystko poszło OK. Musimy wpierw jednak sprawdzić, czy status jest Approved, ale również musimy się upewnić czy jest podany examDate. Nie możemy polegać tylko na sprawdzeniu daty egzaminu, bo przy odrzuconym wniosku również ją dostajemy! Sporo tego sprawdzania (a to tylko trzy statusy!). Mało tego - całe sprawdzanie jest na naszych barkach. Musimy pamiętać o tym, że examDate może pojawić się również przy statusie Rejected, musimy pamiętać, że Rejected ma reason, a inne nie mają, itd., itd..
I teraz wyobraźmy sobie, że przychodzi nowa osoba do projektu… nie zapomnijmy jej o tym wszystkim opowiedzieć! 😅
Czas zrobić porządek. Zacznijmy od tego, że nie potrzebujemy tutaj enuma. Enum nie jest specjalnie lubiany w środowisku. Nie jest type-only, więc tworzenie każdego nowego enuma wiąże się z dodatkowym kodem wkompilowanym w nasze źródła JS. Co by się więc stało, gdybyśmy zamiast enuma użyli literał? Odpowiedź brzmi: nic złego by się nie stało! Ba, literały są type-only, więc JS lubi to, bo może je zignorować. Zatem pierwsza zmiana wyglądałaby następująco:
type Submission = {status: "Approved" | "Rejected" | "Pending";reason?: string;examDate?: Date;};const popUpNewDate = (submission: Submission) => {if (submission.status === "Approved" && submission.examDate) {alert(submission.examDate.toString());}};
Ktoś może zapytać: po co zatem enumy? Mają sens wtedy, gdy musimy przeiterować po wartościach. W takich wypadkach literały nam nie pomogą (chyba, że tworzymy je za pomocą biblioteki, np. io-ts, ale to inna bajka).
Nie traktuję też poważnie argumentu o potrzebie wymiany literału w każdym możliwym wystąpieniu, jeśli jest literówka lub backend zmieni wartości (co zdarza się przecież rzadko). Chyba, że piszemy kodzik w notatniku, a nie w IDE. Ale w takiej sytuacji już i tak ratunku nie ma, więc… ;)
Wracając do meritum: nasz kod jest już prostszy, ale wciąż bije w oczy to, że popUpNewDate musi tak dużo wiedzieć o zależnościach w tym obiekcie. Uprośćmy to i pozwólmy, żeby aplikacja sama o tym “myślała”. Z pomocą przyjdą nam unie dyskryminowane.
Ich konstrukcja jest bardzo prosta. Unie, upraszczając, to różne warianty typów. status w poprzednim listningu jest właśnie unią typów literalnych, bo może przyjąć wartość Approved lub Pending lub Rejected. TypeScript pozwala nam w podobny sposób stworzyć unię różnych typów z kilkoma polami. Potrzebny jest jednak pewien “wyróżnik”. Wartość, która zidentyfikuje nam przynależność do któregoś z wariantów. Tutaj najlepiej sprawdzają się literały, które są naszymi identyfikatorami:
type Animal =| {type: "dog";bark: () => void;}| {type: "cat";goToRoof: () => void;}| {type: "sloth";sleep: () => void;};
Za pomocą unii połączyliśmy trzy różne typy zwierząt o zupełnie innych cechach (supermocach?). Wszystkie kryją się pod typem Animal, różnią się jednak zestawem metod. Gdy upewnimy się w kodzie, że animal.type === 'dog' wtedy kompilator nie pozwoli nam wywołać żadnej innej metody poza animal.bark()! Od teraz on bierze tę odpowiedzialność na siebie.
Rzućmy okiem na nasz kodzik i sposób w jaki zmieniliśmy go na unię dyskryminowaną:
type Submission =| {status: "Approved";examDate: Date;}| {status: "Pending";}| {status: "Rejected";examDate: Date;reason: string;};const popUpNewDate = (submission: Submission) => {submission.status === "Approved" && submission.examDate.toString();if (submission.status === "Pending") {alert(submission.reason); // Property 'reason' does not exist on type '{ status: "Pending"; }'}};
A teraz słówko wyjaśnienia. Do Submission podstawiamy tak naprawdę trzy różne typy. Każde rozróżnione literałem status i to właśnie ów status dyskryminuje inne typy. Jeśli mamy obiekt o statusie Approved, mamy pewność, że dostajemy również examDate. Nie musimy tego sprawdzać, upewniać się za każdym razem. Tę regułę biznesową obsługuje nam TypeScript! Podobnie z Rejected - TS doskonale zdaje sobie sprawę z tego, że obiekt będzie zawierał examDate, ale też, że istnieje jakiś reason.
W takiej sytuacji przenosimy sprawdzanie reguł biznesowych na kompilator. Poza tym, pierwszy rzut oka na Submission mówi nam tak wiele na temat tego, jak wygląda faktycznie domena, że w zasadzie nie potrzebujemy żadnych dodatkowych wyjaśnień od kolegów czy koleżanek ;).
Unie dyskryminowane upraszczają życie, upraszczają kod i dokumentację. TypeScript podsuwa nam świetne narzędzie do opisywania świata reguł biznesowych. Aż żal z niego nie skorzystać. Skorzystać i spać spokojnie. W kolejnym wpisie pokażę, jak za pomocą unii dyskryminowanych możemy uszczelnić API naszych komponentów reactowych, żeby krnąbrni developerzy nie psuli naszego kodu.
Mam nadzieję, że znaleźliście tutaj coś wartościowego dla siebie :). Będę wdzięczny za każdy komentarz i opinię! Tymczasem: Live long and code 🖖