Czym jest enum?
Enum to typ wyliczeniowy szeroko używany w niemal całym świecie programowania - nie jest to jedynie struktura dostępna w TypeScript. Stosuję się go w między innymi w takich językach, jak Java, C#, Python i wielu innych.
Ale co właściwie oznacza, że jest to "typ wyliczeniowy"? To bardzo książkowa definicja, która nie wyjaśnia zbyt wiele, dlatego postaram się to opisać w nieco bardziej przystępny sposób.
W świecie TS, enum (skrót od enumerate, czyli wyliczać) to struktura danych, która na pierwszy rzuta oka bardzo przypomina obiekt. Podobnie do obiektu posiada pary klucz-wartość, przy czym wartość nie musi być jawnie przypisana. Od obiektu odróżnia go jednak fakt, że cała struktura jest niemutowalna, a więc nie może zostać w żaden sposób zmieniona w trakcie działania aplikacji. Enum jako jedna z nielicznych konstrukcji TypeScriptu istnieje w kodzie po skompilowaniu do JavaScriptu (deklaracje typów są usuwane) i może być wykorzystany w czasie działania aplikacji (runtime), a nie tylko do momentu kompilacji.
Enum wykorzystamy tam, gdzie potrzebne są stałe wartości, które mogą być w prosty sposób odczytane, a jednocześnie należą do tej samej grupy (są ze sobą w jakiś sposób powiązane).
Praktyka
Jeżeli jeszcze nie masz w głowie pełnego obrazu, gdzie zastosować, ani jak utworzyć enum, to wszelkie wątpliwości powinny zostać rozwiane po zobaczeniu przykładów.
Wyobraź sobie, że w Twojej aplikacji użytkownik ma możliwość zalogowania, a w zależności od jego roli (administrator, moderator, itd.) powinien zobaczyć różny UI (user interface - widoczna dla klienta część aplikacji). Kiedy pobieramy użytkownika z API, otrzymujemy obiekt, który przedstawiłem poniżej za pomocą interface'u:
Jak widzisz, pole role
, które odpowiada, za rolę użytkownika, to unia literałów typu number
, a konkretnie 0 | 1 | 2
. W tym przypadku 0
oznacza, że użytkownika ma standardowe uprawnienia, 1
to moderator, a 2
administrator.
Problem polega na tym, że są to tak zwane magiczne liczby (magic numbers), czyli wartości liczbowe, które na pierwszy rzut oka nie mają żadnego sensu i trzeba się domyśleć co oznaczają. Stosowanie ich w kodzie może znacznie obniżyć jego czytelność. Spójrz na przykład poniżej:
Jest to prosty komponent React w którym stosuję następujący warunek user.role === 2
. Służy on do warunkowego wyświetlania innego komponentu - AdminNav
. Osoba czytająca ten kod, musi się domyśleć, że 2
oznacza w tym projekcie rolę administratora. Jest to możliwe tylko dzięki temu, że w nazwie warunkowo renderowanego komponentu znajduje się słowo admin. Gdyby nazywał się inaczej, np. ExtendedNav
, to niemal niemożliwe staje się zrozumienie co robi ten warunek, a tym samym odpowiedź na pytanie dlaczego użytkownik, którego rola jest równa 2
może zobaczyć dodatkowy komponent?
Ten przydługi wstęp miał na celu zobrazować wartość, która płynie z użycia enuma. Zapiszę jeszcze raz ten sam przykład, jednak tym razem zastosuję enum. Zwróć uwagę na czytelność poniższego przykładu:
Omówmy zmiany, w stosunku do poprzedniej wersji kodu:
Przede wszystkim stworzyłem
enum Role { ... }
, który przechowuje wszystkie możliwe role w aplikacji. Użyłem do tego słowa kluczowegoenum
, po którym występuje nazwa (jest ona dowolna). Zgodnie z konwencją powinna zacząć się od wielkiej litery.Zamiast porównywać
user.role === 2
, stosuję porównanieuser.role === Role.Admin
, które jest zdecydowanie czytelniejsze, ponieważ nie występuje w nim magiczna liczba, zamiast niej mamy znacznie bardziej czytelny zapis.
Pewnie zauważyłeś/aś już, że nie przypisałem żadnych wartości do kluczy. Taki zapis w TS jest poprawny i zostaną wtedy użyte domyślne wartości numeryczne, zaczynając od 0. Zatem None
ma wartość 0
, Moderator
ma 1
, a Admin
2
.
Wartości domyślne i nie tylko
Zapis, który użyłem wyżej (wykorzystujący domyślne wartości) nie jest jedyną możliwością. Okazuje się, że enum może przyjąć jako wartość typ number
lub string
. Żaden inny typ nie jest akceptowany.
Poniżej pokazuję, w jaki sposób utworzyć enum z jawnie zadeklarowanymi wartościami liczbowymi, oraz tekstowymi (zauważ, że używamy tu operatora =
, a nie :
jak to ma miejsce w obiekcie). Możliwe jest również wymieszanie tych dwóch typów wewnątrz jednego enuma.
Możesz się spotkać z jeszcze jednym zapisem, który korzysta zarówno z wartości domyślnych, jak i z jawnych:
Enum Role
ma jawnie przypisaną wartość liczbową do None
i jest to 1000
. Pozostałe dwie wartości są niejawnie przypisane, jednak tym razem nie zaczynają się od 0
, a 1000
(pod uwagę jest brana wartość poprzedzająca). Ten sposób nie działa w przypadku, gdy przekażemy wartość typu string
.
Zapis przedstawiony poniżej również działa poprawnie, ale jego czytelność to kwestia mocno dyskusyjna 😉, dlatego zdecydowanie lepiej jest nie stosować tego typu mieszanki wartości domyślnych i jawnych.
Zastosowanie i alternatywy
Na tym etapie zastosowanie enuma może jeszcze nie być do końca jasne, dlatego postaram się przedstawić kilka sytuacji, w których może się okazać przydatny. Omówimy też kilka alternatywnych podejść.
Wyobraź sobie, że tworzysz funkcję, która przyjmuje jeden argument typu string
, a następnie na jego podstawie zwraca wynik. Przykładowa implementacja może wyglądać tak:
Jak widzisz, otypowanie parametru variant
jako string nie zabezpiecza nas przed popełnieniem błędu. Problem ten można rozwiązać stosując enum:
W tym przykładzie parametr variant
został otypowany jako Variant
, czyli utworzony wcześniej enum. Oznacza to, że enuma można użyć zarówno jako typ, jak i wartość. Jeżeli teraz spróbuję wywołać funkcję w następujący sposób: getLabel("error")
to otrzymam błąd, który mówi, że muszę użyć wartości z enuma Variant
. To z kolei oznacza, że w każdym miejscu, w którym używam tej funkcji muszę również mieć dostęp do Variant
.
Takie podejście może się okazać niewygodne dla osób, które będą korzystać z Twojej funkcji, dlatego odradzam stosowanie enumów w przypadku, gdy tworzysz np. bibliotekę, która następnie będzie używana przez wielu różnych użytkowników.
Istnieje również kilka alternatywnych podejść do rozwiązania problemu, który opisałem wyżej. Najprostszą alternatywą jest po prostu zastosowanie unii typów (w tym przypadku będzie to unia literałów stringa) zamiast bardzo ogólnego typu string
:
W tym przypadku nie muszę wykorzystywać enuma, a TypeScript nadal poprawnie podpowiada możliwe opcja i wykrywa błędy. Warto jednak pamiętać, że to rozwiązanie działa tylko do momentu kompilacji (typy są usuwane po skompilowaniu do JavaScriptu).
Nieco większe bezpieczeństwo gwarantuje użycie obiektu zamiast enuma. Zaimplementujmy tą samą funkcję, ale zastosujmy obiekt:
Zwróć uwagę na zastosowany zapis as const
po utworzeniu obiektu. Gdybym go nie dodał, to poprawne otypowanie parametru byłoby niemożliwe. Stosując obiekt musimy także napisać nieco więcej kodu przy parametrze (enum może być stosowany jako typ, obiekt nie) - typeof VARIANTS[keyof typeof VARIANTS]
sprawia, że "wyciągamy" z obiektu wszystkie możliwe wartości i zamieniamy je w typ (więcej na ten temat znajdziesz w artykule o typach mapowanych).
Oczywiście mógłbym po prostu utworzyć unię typów jak w poprzednim przykładzie i w ten sposób otypować parametry, ale takie rozwiązanie nie jest idealne - za każdym razem, gdy zmienimy obiekt (np. dodamy nową właściwość) musimy także aktualizować typ. W obecnym rozwiązaniu nie ma takiej potrzeby, ponieważ typ jest tworzony na podstawie obiektu.
Dodatkowo as const
sprawia, że obiekt staje się niemutowalny (przynajmniej do momentu kompilacji). Podobny efekt możemy osiągnąć za pomocą metody Object.freeze
, jednak tutaj będziemy chronieni także w trakcie działania aplikacji.
Zauważ, że stosując podejście z obiektem pozostawiamy decyzję o sposobie wywołania osobie, która będzie używać funkcji. Można wykorzystać właściwość z obiektu getLabel(VARIANTS.ERROR)
lub po prostu przekazać string getLabels("error")
.
Minusy enumów
Do tej pory przedstawiałem głównie plusy zastosowania enumów. Nie mogę jednak nie wspomnieć, że wśród użytkowników TypeScriptu istnieje całkiem spora grupa (do której sam się zaliczam), która odradza używania enumów. Dlaczego?
Powodów jest kilka. Po pierwsze enum to jedna z niewielu konstrukcji w TS, która po skompilowaniu do JS nadal istnieje w kodzie, a jej implementacja różni się, w zależności od użytych wartości (a dodatkowo jest bardzo nieczytelna dla ludzkiego oka). Za przykład może posłużyć używany wcześniej enum Role
:
W JavaScript mamy do czynienia ze zmienną typu var
o nazwie identycznej jak utworzony enum. Następnie za pomocą IIFE (immediately invoked function expression) przypisywany jest do niej obiekt, który posiada dwa razy więcej kluczy niż enum, ponieważ wiązania tworzone są w obie strony: 0: "None"
, oraz None: 0
.
Sytuacja komplikuje się jeszcze bardziej, jeżeli przypiszemy wartości tekstowe, a nie liczbowe. Wtedy wiązania są tworzone tylko w jedną stronę (dokładnie tak, jak to wygląda w oryginalnym zapisie). Nie jest to intuicyjne rozwiązanie, a lista minusów dopiero się zaczyna 😬.
Drugim najważniejszym argumentem jest to, że korzystając z enumów narażamy nasz kod na potencjalne problemy z kompatybilnością w przyszłości. Jeżeli do JavaScriptu zostaną wprowadzone enumy, to może się okazać, że występuje konflikt pomiędzy tymi dwoma językami.
Nie jest to jednak koniec problemów, a sami twórcy TypeScripta przyznają, że enumy posiadają wiele wad i sugerują zastąpienie ich wcześniej wspomnianymi rozwiązaniami. Więcej na ten temat możesz przeczytać w oficjalnej dokumentacji.
W mojej subiektywnej opinii należy unikać tych struktur z TS, które zostawiają po sobie ślad po kompilacji do JS i ograniczyć w ten sposób wpływ na finalny kod. Zachowasz dzięki temu znacznie większą kontrolę nad tym, co ostatecznie trafi do aplikacji nad którą pracujesz, a TypeScript będzie istniał w Twoim kodzie jedynie do momentu kompilacji.
Podsumowanie
Zalety używania enuma powinny być na tym etapie oczywiste. Nie mogę jednak nie wspomnieć o tym, że wśród użytkowników TypeScripta znajdziesz wielu przeciwników wykorzystywania tej konstrukcji języka, dlatego przedstawiłem też kilka alternatywnych rozwiązań, a w internecie możesz ich znaleźć znacznie więcej do czego bardzo Cię zachęcam.
Ostateczną decyzję pozostawiam Tobie, jednak zanim zdecydujesz się wykorzystać jakąś funkcjonalność języka, warto być świadomym jej plusów i minusów. Dzięki temu będziesz mógł / mogła dobrać odpowiednie rozwiązanie do danej sytuacji.