😫 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 без недостатков.
Справедливости ради, я не встречал такой необходимости ни разу за всю работу.