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!