😫 Enum - oh shit were go again

Давайте поговорим здесь про enum'ы в typescript. Как можно догадаться из названия - я не очень хорошо отношусь к этой структуре. Казалось бы, удобная вещь, можно использовать значение как тип, не нужно указывать значение - TS любезно сгенерирует чиселки.

Но как только вы начинаете работать в большом проекте, можно наткнуться на не очень приятные последствия.

Беда не приходит одна

Я шота не понял...

Представим такую ситуацию, создается компонент:

export enum ButtonSize {
    Small,
    Medium,
    Large,
}

export type Props = {
    size: ButtonSize;
};

export function Button(props: Props) {
    return <button />;
}

Используется он в каком то другом месте:

return <Button size={ButtonSize.Large} />;

С первого взгляда все хорошо, но давайте создадим enum, который будет абсолютной копией enum ButtonSize. Положим в отдельный файл:

// some.ts

export enum Size {
    Small,
    Medium,
    Large,
}

Теперь этот enum попытаемся использовать как значение для Button:

<Button size={Size.Large} />

Внезапно получается ошибка:

Type 'Size.Large' is not assignable to type 'ButtonSize'.ts(2322)

"И правильно!" скажут бравые кодеры, "Так и должно быть! Ты же породил разные сущности, хоть они по начинке и одинаковые, но тип то должен быть разный.". Я в целом согласен с этой позицией. Но тогда я не понимаю почему, если я даю имя, которое совпадает с именем из компонента:

// some.ts

export enum ButtonSize {
    Small,
    Medium,
    Large,
}

То теперь вдруг все начинает работать! Это непоследовательное поведение создает проблемы при рефакторинге.

Иногда это мешает, из-за например автогенерации кода, например я значение получаю от сервера и хотел бы использовать его напрямую, без небезопасного каста через as. С size это конечно тяжело представить, но вот если у вас компонент например User, а у него есть поле type, которое имеет тип перечисления, а с сервера приходит поле role, с enum типом, которое было автоматически сгенерировано с именем Role, и при этом они зеркальны - я бы хотел использовать их напрямую и получать ошибку только, если типы реально несовместимы.

Впихуется в невпихуемое

Здесь речь пойдет по такие страшные слова как ковариантность, контравариантность, инвариантность. Ну вот понтанулся и больше я их говорить не хочу.

Будем говорить проще и понятнее. Любой программист понимает, что есть более широкий тип, а есть более узкий тип. Например в ООП, более широким типом считается родитель, а дети его более узким вариантом. В мат части это называется "суммой типов" - это широкий, а единичный тип - это узкий, проще говоря когда вы тип описываете через вертикальную черту |, то это сумма, и он широкий, а узкий, это каждый член этого типа.

Если я скажу, что подставить широкий тип, вместо узкого - это грубое нарушение законодательства теории типов, то я не должен вас удивить. Это как попытаться запихать огромную сумку в маленький кошелек. Без проверки в ходе исполнения программы, это чревато тем, что у более широкого типа будут не те свойства, методы или данные, что ожидается.

Пример:

type Role = 'admin' | 'user'

const hasPermission = (role: Role) => {/*...*/}

// ...

type User = {
  role: string // обратим внимание, тип более широкий чем Role
}

const user: User;

// type error
if (hasPermission(user.role)) {
  // ...
}

// -------------------------------------------------

// type guard
const isRole = (str: string) str is Role => str === 'admin' || str === 'user'

const { role } = user
// this is correct
if (isRole(role) && hasPermission(role)) {
  // ...
}

В примере видно, что более широкий тип нельзя использовать там, где требуется более узкий, а так же, как его можно сузить. Если вы используете более узкий вместо широкого - это нормально, маленький кошелек поместится в большую сумку.

И зачем я сделал эти прелюдии?

А вот зачем, объявим переменную size с типом number и используем в качестве значения для пропсы size компонента Button. Ваши ставки господа:

const size: number = 20

<Button size={size} /> // this is fine

Это нормально для TS. более того, ему и с NaN будет хорошо:

export default function App() {
    const size = NaN
    return (
        <div className="App">
            <Button size={size}>
        </div>
    )
}

Закомпилился

А теперь нужно посмотреть во что же компилируется enum:

export var ButtonSize;
(function (ButtonSize) {
    ButtonSize[(ButtonSize['Small'] = 0)] = 'Small';
    ButtonSize[(ButtonSize['Medium'] = 1)] = 'Medium';
    ButtonSize[(ButtonSize['Large'] = 2)] = 'Large';
})(ButtonSize || (ButtonSize = {}));

Я не хочу говорить, что var - это не очень безопасная штука(можно значение перезаписать, и это может произойти при сложных схемах сборки из-за коллизии имен), и что enum можно случайно объявить дважды в одном файле, это все крайне маловероятно составит проблемы.

Для меня здесь самая большая проблема - это то, что он генерирует обратный маппинг. Зачем, а главное почему? Если я хочу сделать цикл без фильтрации мне не обойтись, а если enum достаточно крупный, то это удвоение может сыграть на струнах вашего перфоманса.

Итоги

Enum - с виду красивое и удобное решение, но имеет ряд недостатков:

  • Небезопасен в рантайме
  • Небезопасен на уровне типизации
  • Непоследовательное и непредсказуемое поведение
  • Генерирует лишний мусор

А шо теперь делать?

Есть два решения - сумма типов и const enum. Я же придерживаюсь первого и сейчас расскажу как. Для начала я создаю просто объект:

export const sizes = {
    small: 0,
    medium: 1,
    large: 2,
} as const;

На конце обязательно должен быть as const, иначе в значениях будет number, или string, что нам не подходит.

Один раз в каком-то глобальном d.ts файле необходимо прописать такой тип-хелпер:

declare type ValueOf<T> = T[keyof T];

Я его указываю в глобальном дифинишине чтоб не таскать каждый раз импорт. Он работает как keyof, но только по значениям объекта. Использование:

export type Props = {
    size: ValueOf<typeof sizes>;
};

Теперь посмотрим на использование:

<Button size={sizes.large} /> // fine
<Button size={1} /> // work

const size: number = NaN;
<Button size={size} /> // type error

const size: number = sizes.large;
<Button size={size} /> // type error

Теперь не получается обмануть тип и подсунуть ему любое число без каких-либо кастов. Давайте посмотрим, как будет работать если мы создадим отдельный список размеров:

// some.ts
export const someSizes = {
  small: 0,
  medium: 1,
  large: 2,
  extraLarge: 3
} as const;


// App.tsx

<Button size={someSizes.small} /> // fine, works
<Button size={someSizes.extraLarge} /> // type error

Давайте попробуем тип ValueOf<typeof someSizes> использовать для пропса size:

// Создадим функцию, потому что просто переменная там не подойдет, потому TS кастит тип к примитиву при использовании
const getSize = (): ValueOf<typeof someSizes> => someSizes.large

// large совместим с Button, но возвращаемый тип будет 0 | 1 | 2 | 3
<Button size={getSize()} /> // type error

В итоге получается более безопасный вариант для enum, не будет конфликтовать при кодогене, можно использовать литералы при сравнении (более короткий и лаконичный вариант, если это строки):

export const sizes = {
    small: 'small',
    medium: 'medium',
    large: 'large',
} as const;

type Size = ValueOf<typeof sizes>;

const isSuperSize = (size: Size) => size === 'large';

Это же может помочь вам избавиться от импортов.

Так же удобно использовать цикл по таким объектам, ибо там нет лишнего.

А ну-ка отрефактори

Самый жирный козырь в рукаве у любителей enum'ов, как они думают - это рефакторинг. Но на самом деле это не так. Я ни разу не видел реального рефакторинга, в котором enum был на коне.

Данный тип легко подлежит рефакторингу через VSCode пример (F2). Делается это и правда не так красиво, сначала тип нужно будет преобразовать в то, что отдает ValueOf (IDE любезно подскажет):

type Size = 'small' | 'medium' | 'large';

Потом поставить курсор на нужном значении, нажать F2 и изменить значение, вернуть ValueOf. Изменить его в объекте и все снова будет работать. Но если вы откажитесь от использования литералов в остальном коде - этого минуса вообще не испытаете. Технически у вас остается тот же самый enum без недостатков.

Справедливости ради, я не встречал такой необходимости ни разу за всю работу.