Co to jest branding?
Żeby zrozumieć do czego służy technika brandingu, musimy najpierw wiedzieć, że TypeScript nie wspiera typów nominalnych, zamiast tego opiera się na typowaniu strukturalnym.
Porównanie tych dwóch systemów to temat na osobny artykuł, ale postaram się to nieco uprościć i streścić. Żeby zrozumieć różnicę pomiędzy tymi podejściami posłużymy się przykładem.
Ten przykład idealnie obrazuje to, co opisałem wyżej - TS nie sprawdza, czy dany obiekt jest instancją konkretnej klasy, interesuje go jedynie struktura (metody, właściwości). W tym przypadku jest ona taka sama, dlatego do zmiennej somePerson
(otypowanej jako Person
) bez problemu mogłem przypisać obiekt utworzony na podstawie klasy Animal
.
Analogicznie TypeScript pozwolił na przypisaniej do zmiennej someAnimal
(która również jest jawnie otypowana jako Animal
) obiektu utworzonego na podstawie klasy Person
.
W systemie typów, który wspiera typy nominalne sytuacja wyglądałaby zupełnie inaczej - pomimo, że dwa obiekty mają dokładnie ten sam kształt, to nie moglibyśmy przypisać ich do zmiennych, które nie zostały otypowane z użyciem dokładnie tego typu. W dość dużym uproszczeniu możemy sobie wyobrazić, że taki system stosuje sprawdzenie: x instanceof y
(co w TS nie ma miejsca).
Właśnie ten problem rozwiązuje branding. Wprowadza namiastkę systemu nominalnego do TypeScripta, a co za tym idzie zapobiega przypadkowemu użyciu wartości w miejscu, gdzie oczekiwany jest konkretny typ, nawet jeżeli jej struktura odpowiada temu, co opisuje dany typ.
Generyczny typ brand
Jeżeli wstęp wydał Ci się skomplikowany to nie przejmuj się - gdy tylko przejdziemy do praktyki, to wszystko stanie się nieco bardziej zrozumiałe.
Na początek stworzymy generyczny typ Brand, który przyjmie dwa argumenty
Inny typ lub alias
Literał stringa służący do tworzenia kolejnych typów nominalnych
Implementacja wygląda następująco:
Jeżeli wcześniej nie spotkałeś się z generycznymi typami, to napisałem na ten temat osobny artykuł, który wprowadzi Cię w podstawy, znajdziesz go tutaj. Poza zastosowaniem typu generycznego użyłem również słowa declare oraz typu unique symbol, dzięki temu TS "myśli", że istnieje zmienna brand, która przechowuje unikatowy symbol. Ostatnio krok to wykorzystanie jej w typie.
Typ, który przyjmiemy jako pierwszy argument zostaje połączony za pomocą operatora &
z obiektem, który posiada jeden klucz - jest nim zadeklarowana wcześniej zmienna. Następnie jako wartość przypisujemy drugi argument, czyli literał stringu.
Zastosowanie
Istnieje spora szansa, że zastanawiasz się, gdzie tak naprawdę powinniśmy to wykorzystać i kiedy w ogóle jest potrzebna ta technika. Zacznijmy ponownie od przykładu, a później przyjdzie czas na trochę teorii.
Na początku tworzymy nowy typ brandowany - ValidPerson
. Następnie deklarujemy funkcję validatePerson
, które ma za zadanie upewnić się, że mamy do czynienia z poprawnym obiektem person. Jeżeli walidacja nie przejdzie pomyślnie, to zostaje rzucony wyjątek, a jeżeli wszystko się powiedzie, to zwracamy wartość przekazaną w argumencie. Kluczowe jest to, że używamy tu rzutowania (casting) do zmiany typu na utworzony wcześniej ValidPerson
.
Załóżmy teraz, że inne funkcje w programie również korzystają z typu Person
. Zamiast tego, możemy zmienić typy, które przyjmują i posługiwać się ValidPerson
. Taka zmiana wymusza na nasz stosowanie funkcji validatePerson
w celu upewnienia się, że mamy do czynienia z poprawnymi wartościami, ponieważ tylko ta funkcja zwraca nam wartość typu ValidPerson
.
Oczywiście to nie jedyna możliwość użycia typów brandowanych. Można je także stosować w celu upewnienia się, że przekazana zostanie poprawna jednostka.
Załóżmy, że nasza funkcja przyjmuje dwa argumenty: callback
oraz timeout
, a następnie wywołuje przekazaną funkcję po określonym w drugim argumencie czasie (robimy dokładnie to, co robi setTimeout
).
Błąd jaki można tu popełnić, to przekazanie złej jednostki jako drugi argument. Na pierwszy rzut oka nie wiem, czy dana funkcja oczekuje sekund, milisekund, a może liczby godzin, czasami nawet po wczytaniu się w kod ciężko jest to stwierdzić.
Problem ten częściowo można rozwiązać z pomocą typów brandowanych. Nie uchronią nas one w pełni przed pomyłką, ale znacznie zmniejszą jej prawdopodobieństwo.
Teraz musimy jawnie rzutować typ number
np. na Seconds
, dzięki temu, od razu widzimy, że jednostką powinny być sekundy. Przy takim podejściu trudno o błąd, który popełniłem wcześniej, jednak nie jest on całkowicie rozwiązany - nieuważny programista nadal może po prostu użyć rzutowania i nie zastanowić się nad nazwą typu na który rzutuje.
Jeżeli myślisz, że takie pomyłki się nie zdarzają, to zachęcam do przeczytania tego artykułu. Opisuje on, jak NASA straciła orbiter, którego budowa pochłonęła ponad 125 milionów dolarów z powodu podobnej pomyłki.
Podsumowanie
Wydawać by się mogło, że typy brandowane to rozwiązanie idealne i powinniśmy go używać zawsze i wszędzie. Jednak w programowaniu nic nie jest czarno białe i to podejście również ma swoje minusy.
Przede wszystkim musimy napisać znacznie więcej kodu, tworzyć dodatkowe typy (co może utrudnić czytelność), używać rzutowania i po prostu więcej się narobić.
Oczywiście są też plusy: znacznie większe bezpieczeństwo i kod, który jest łatwiejszy do utrzymania (a czasami także do zrozumienia). Zalecam jednak zachowanie zdrowego rozsądku i nie używanie tego podejścia zbyt często, ponieważ może to doprowadzić do bardzo skomplikowanego systemu typów w aplikacji.
Jak zawsze zachęcam Cię do wypróbowania omówionego tematu w praktyce (w projekcie, przy którem akurat pracujesz, lub w sandboxie dostępnym na stronie TypeScriptu), bo jest to jedyne droga do pełnego zrozumienia danego zagadnienia.