Kategoria:TypeScript

TypeScript - nie potrzebujesz enumów

  • Czas potrzebny na przeczytanie:4 minuty
  • Opublikowane:

Korzystasz z enumów w TypeScript? Enumy mogą spowodować więcej kłopotów niż użytku... Dlaczego są takie złe i jakie są alternatywy?

Typy, a może jednak wartości?

Enumy łamią podstawowe założenie typów w TypeScripcie - nie znikają po kompilacji!

Za przykład weźmy numerycznego enuma Role, po skompilowaniu ten enum zamieni się w takiego potworka:

var Role;
(function (Role) {
  Role[(Role['Admin'] = 0)] = 'Admin';
  Role[(Role['User'] = 1)] = 'User';
  Role[(Role['Guest'] = 2)] = 'Guest';
})(Role || (Role = {}));

Wynika to z faktu, że enumów możemy używać zarówno jako typów, jak i wartości. Poniższy kod jest w 100% poprawny:

Czy to dobrze, czy to źle? Zależy jak na to spojrzeć :) Ze strony samego języka jest to dziwne posunięcie, odbiegające od tego, jak działają type, czy interface. Z perspektywy programisty może być to faktycznie miejscami wygodne, jednak korzystając z enumów, miejmy na uwadze, że tworzymy nadmiarowy kod.

Niebezpieczeństwo numerycznych enumów

Enumy dzielą się na dwa rodzaje - enumy numeryczne i te z ciągami znaków. Te pierwsze są kompletną pomyłką i powodują niebezpieczeństwo w typowaniu!

Numeryczne enumy mają to do siebie, że domyślnie indeksowane są tak jak obiekty, od zera - widzimy to w skompilowanym przykładzie powyżej. Możemy jednak zmienić to zachowanie nadpisując indeks dla konkretnego typu.

Jeśli adminowi nadamy indeks 5, a zwykłemu użytkownikowi 14, to TypeScript patrząc na trzeci element w enumie, spróbuje nadać mu o jeden większy indeks niż poprzednio nadpisany. Kod po skompilowaniu:

var Role;
(function (Role) {
  Role[(Role['Admin'] = 5)] = 'Admin';
  Role[(Role['User'] = 14)] = 'User';
  Role[(Role['Guest'] = 15)] = 'Guest';
})(Role || (Role = {}));

Spróbujmy wykorzystać tego enuma w praktyce. Tworzymy funkcję getUserRole, która przyjmuje rolę o wcześniej stworzonym typie Role:

declare function getUserRole(role: Role): void;

getUserRole(Role.Admin); // ✅
getUserRole(5); // ✅  🤔
getUserRole(200); // ✅  🤔

Oczekiwalibyśmy, że funkcja przyjmie tylko wcześniej zdefiniowanego enuma, jednak tak się nie dzieje... Możemy podać w niej również jakąkolwiek liczbę 🤯

Enumy z ciągami znaków

Przejdźmy do opcji nr. 2 - string enum. To z niego najczęściej korzystamy w TypeScripcie:

Tutaj do danego pola w enumie przypisujemy odpowiadającego mu stringa. Sprawdźmy, jak wygląda wykorzystanie w funkcjach:

declare function getUserRole(role: Role): void;

getUserRole(Role.Admin); // ✅
getUserRole(5); // ❌
getUserRole(200); // ❌

getUserRole('admin'); // ❌ 🤯

Super, Role.Admin działa, nie mamy możliwości przekazania dowolnej liczby, wszystko jest jak należy. Z jednym małym szczegółem. Nie możemy również przekazać stringa, który odpowiada temu, zdefiniowanemu w enumie. Z doświadczenia wiem, że coś takiego jest mega wkurzające, bo za każdym razem musimy skorzystać ze zdeklarowanego enuma.

Typowanie nominalne

To, co jednocześnie jest wkurzające, ma również swoje zalety. Enumy są bowiem typowane nominalnie.

enum Role {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest',
}

enum Role2 {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest',
}

const admin: Role = Role.Admin; // ✅

const admin2: Role2 = Role.Admin; // ❌

Jednego enuma, nie możemy przypisać do drugiego, nawet jeśli oba są identyczne, fajna sprawa :)

const enum

Twórcy TypeScripta próbują obalić jeden z moich argumentów przeciw wykorzystaniu enumów i w swojej kolekcji typów posiadają również takiego potworka jak const enum.

const enum Role1 {
  Admin,
  User,
  Guest,
}

const users1 = [Role1.Admin, Role1.User, Role1.Guest];

/*
Po kompilacji:

const users1 = [0, 1, 2];
*/

const enum Role2 {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest',
}

const users2 = [Role2.Admin, Role2.User, Role2.Guest];

/*
Po kompilacji:

const users = ['admin', 'user', 'guest'];
*/

Dzięki niemu enumy nie zostają w kodzie po kompilacji, a miejsca, w których były użyte, zmieniają się w zależności od typu enuma. Dla enumów numerycznych otrzymujemy indeksy, a dla ciągów znaków odpowiadające im wartości.

Ten sposób typowania ma jednak jedną dużą wadę - nie zadziała w przypadku połączenia Babela z TypeScriptem.

Alternatywy

Tyle zabawy, tyle tradeoffów, po co to wszystko skoro mamy prostsze rozwiązanie?

Wystarczy skorzystać z unii!

type Role = 'admin' | 'user' | 'guest';

declare function getUserRole(role: Role): void;

getUserRole('admin'); // ✅

getUserRole(5); // ❌
getUserRole(200); // ❌

Nasz typ w końcu jest bezpieczny, nie musimy niczego importować, to po prostu działa!

A jeśli chcielibyśmy móc korzystać z typów i wartości, tak jak w enumie, to możemy użyć zwykłego obiektu:

const Role = {
  Admin: 'admin',
  User: 'user',
  Guest: 'guest',
} as const;

type Values<Object> = Object[keyof Object];

declare function getUserRole(role: Values<typeof Role>): void;

getUserRole('admin'); // ✅
getUserRole(Role.Admin); // ✅

Podsumowanie

Enum to dziwne stworzenie w TypeScripcie. Pełne złych decyzji projektowych i tredeoffów. W większości zastosowań enuma możemy go zastąpić zwykłymi uniami typów oraz, gdy chcemy skorzystać również z wartości, zwykłych obiektów.

O autorze

Olaf Sulich

Olaf jest Frontend Developerem, blogerem i nosi rybacki kapelusz 🎩 Pisze o wszystkim co związane z frontendem, ale nie boi się backendu i designów 🦾 Ma głowę pełną pomysłów i nadzieję, że znajdziesz tutaj coś dla siebie!

Dołącz do społeczności!

Bo w programowaniu liczą się ludzie

Wchodzę