Dlaczego unikam typów wyliczeniowych w TypeScript?
Jak wspomniałem w poprzednim artykule kod TypeScript po transpilacji nie zostawia śladu z wyjątkiem typów wyliczeniowych.
Dzisiaj kompleksowo wyjaśnię, dlaczego warto unikać enumów i co w zamian.
Problem 1
W TypeScript enumy można zadeklarować na dwa sposoby const enum oraz enum.
const enum Direction {
Up,
Down,
Left,
Right,
}
enum Direction {
Up,
Down,
Left,
Right,
}
Różnica jest ogromna, ponieważ deklarując bez const po transpilacji w runtime zastaniemy coś takiego:
"use strict";
var Direction;
(function (Direction) {
Direction[(Direction["Up"] = 0)] = "Up";
Direction[(Direction["Down"] = 1)] = "Down";
Direction[(Direction["Right"] = 2)] = "Right";
Direction[(Direction["Left"] = 3)] = "Left";
})(Direction || (Direction = {}));
Stworzył się obiekt, który zostaje w kodzie. Teraz gdy mamy takich enumów kilkadziesiąt, problem robi się znaczny, jak i rozmiar builda.
Nawet gdy jakaś wartość jest nieużywana transpilator i tak ją zostawi. Jedynie IDE nam może podpowiedzieć, że wartość jest nieużywana i możemy ją usunąć.
Ale const enum daje nam pewność, że w runtime nie zostanie nic.
Nie jest to takie oczywiste na co dzień, wielu programistów o tym nie ma pojęcia. Może również powodować dezorientację.
Problem 2
Enumy możemy zapisać jawnie oraz zdać się na domyślne przypisanie zaczynające się od 0.
const enum Direction {
Up,
Down,
Left,
Right,
}
enum Direction {
Up = "Up",
Down = "Down",
Left = 1,
Right = 2,
}
Przypisanie wartości string oraz number w jednym typie wyliczeniowym ma charakter podglądowy i nie jest zalecany!
"use strict";
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up";
Direction[Direction["Down"] = 1] = "Down";
Direction[Direction["Left"] = 2] = "Left";
Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));
var Direction;
(function (Direction) {
Direction["Up"] = "Up";
Direction["Down"] = "Down";
Direction[Direction["Left"] = 1] = "Left";
Direction[Direction["Right"] = 2] = "Right";
})(Direction || (Direction = {}));
Wyobraźmy sobie sytuacje, że nie mamy przypisanych wartości i dodamy kolejny enum na początku lub końcu, co się stanie? Ponownie się wyliczy, zaczynając od 0, co wprowadzi nieoczekiwane skutki w trakcie działania aplikacji.
Po transpilacji widać ogromny narzuć dodatkowego kodu, ponadto można zauważyć, że automatycznie przypisano wartości od 0 do 3.
Teraz jeśli zamienię kolejność Left z Right, wartości ponownie się przeliczą od góry do dołu. Nie ma nad tym żadnej kontroli, może to powodować nieoczekiwane skutki oraz błędy w aplikacji.
Jedynym wyjściem jest przypisanie na sztywno wartości co też nie zawsze jest oczywiste.
enum Direction {
Up = "up",
Down = 1,
Right = 3,
Left = "left",
}
Problem 3
Załóżmy, że przychodzi nam JSON o interfejsie który zdefiniujemy tak:
interface Item {
id: number;
title: string;
status: Status;
}
enum Status {
SUCCESS = "SUCCESS",
PENDING = "PENDING",
CANCEL = "CANCEL",
}
w trakcie pisania kodu chcemy coś zrobić stosujac warunek i mamy na to dwa rozwiązania:
if(item.status === "SUCCESS");
if(item.status === Status.SUCCESS);
Pierwszy warunek bardziej jest odporny na błedy, ponieważ, można podstawić tylko wartości, które są w enumie, np. wpisując SUCCESSS dostaniemy błąd: This comparison appears to be unintentional because the types ‘Status’ and ‘“SUCCESSs”’ have no overlap.(2367)
W drugim warunku przypisujemy klucz Status.SUCCESS i teraz gdy zrobimy literówkę w wartości enuma, niestety, ale kompilator nie podkreśli błędu.
ale…
Jeśli mamy metodę o parametrze Status i przypiszemy stringa zgodnego z wartością enuma dostaniemy błąd.
function getStatus(status: Status): Status {
return status;
}
getStatus("CANCEL") // błąd
getStatus(Status.CANCEL) // prawidłowo
Argument of type ‘“CANCEL”’ is not assignable to parameter of type ‘Status’.(2345)
Na pierwszy rzut oka może się wydawać niezrozumiałe.
Problem 4
Każdy enum, który mamy w innym pliku musimy importować.
Pisząc np. w Angularze, gdy chce wykorzystać go do porównania czegoś w html, muszę najpierw zaimportować do komponentu i dopiero się odwołać.
@if(status === Status.SUCCESS) { // wymaga importu Status
//kod
}
IDE nie jest w stanie mi podpowiedzieć jakie wartości mogę porównać, co jest uciążliwe w trakcie pisania kodu.
Drugą sprawą jest to, że jeśli mamy z 20 różnych importów typów wyliczeniowych, to kod nieco się rozrasta.
Jeśli musimy wykorzystywać zbyt dużo enumów w jednej klasie, komponencie to może warto zastanowić się nad refaktorem całości, bo być może za dużo się dzieje.
Ale co zamiast?
No właśnie, tyle wad enumów, w sumie to same ( ͡° ͜ʖ ͡°), ale co w zamian?
Unia typów
Czyli nic innego jak alias typów, który ma w sobie kilka typów.
type Status = "SUCCESS" | "PENDING" | "CANCEL";
type Id = number | string; // tylko number lub string
type updateDate = Date | null; // data lub null
type User = { id: Id, name: string, age: number } // obiekt wraz z id o typie Id
Są bardzo elastyczne, możemy zawężać do określonych typów, umieszczać typy w typach czy nawet typować obiekty, funkcję.
Na temat typów można by zrobić osoby artykuł, bo jest tego sporo.
Ale tutaj najlepiej będzie podsumować w kilku punktach:
- nie zostawiają śladu w runtime,
- prostota,
- są elastyczne, wykorzystuje się np. do template literal types,
- większa czytelność oraz intuicyjne wykorzystanie,
- transpiler od razu wyrzuca błąd, gdy dostanie nieoczekiwaną wartość,
- IDE podpowiada jakie wartości mogę porównać (auto-complete), nie potrzeba żadnych importów,
- IDE od razu informuje mnie o błędnej wartości,
- nie trzeba robić przypisania klucz <> wartość, przez co jest mniej podatne na literówkę w wartości,
Od autora
Już od dobrych kilku lat nie używam typów wyliczeniowych w projektach.
A w istniejących gdzie trafiam na enum staram się zamieniać (oczywiście za zgodą całego zespołu), zwykle taki refaktor jest bezproblemowy i szybki!