Перечисления — это сокращение от перечислений, которые представляют собой набор предопределенных постоянных значений. Все они представляют одну и ту же идиому программирования — список фиксированных известных значений. Скрытая правда заключается в том, что перечисления TypeScript передаются в объекты JavaScript неинтуитивным способом.

🤺 Действительно ли перечисления TypeScript вредны?

Одной из текущих тенденций в области TypeScript, похоже, является критика нативных перечислений TypeScript и утверждение, что их небезопасно использовать.

Впервые я столкнулся с драмой перечисления в видеороликах от крупных создателей в этой области, таких как Мэтт Покок и Тео, которые категорически против их использования с театральными названиями, такими как «Худшая особенность TypeScript», Перечисления считаются вредными» и мой личный фаворит «Перечисления причиняют мне боль».

Первоначально я игнорировал эти мнения как чрезмерный анализ несущественных функций TypeScript, но как только комментарии по обзору кода начали предлагать альтернативы собственным перечислениям TypeScript, мне стало искренне любопытно — действительно ли мое использование перечислений опасно и вредит качеству моей кодовой базы?

❓ Что такое перечисления?

Для тех, кто еще не знаком с перечислениями TypeScript, вот краткий обзор. Перечисления — это сокращение от перечислений, которые представляют собой набор предопределенных постоянных значений.

Перечисления — это идиома, используемая в большинстве популярных языков программирования и обычно существующая как конструкция данных, которая фиксирует некоторый исчерпывающий список фиксированных значений — перечисление направлений на компасе может выглядеть в TypeScript следующим образом:

enum CompassDirections {
  NORTH
  EAST
  SOUTH
  WEST
}

Существует большое разнообразие перечислений TypeScript, таких как числовые перечисления, строковые перечисления, гетерогенные перечисления, константные перечисления, объединенные перечисления, окружающие перечисления и еще!

По сути, все они представляют один и тот же язык программирования — список фиксированных известных значений.

👎 Почему не стоит использовать Enums?

На первый взгляд, перечисления великолепны — они позволяют хранить набор значений только для чтения в лаконичном и безопасном для типов виде. Однако в некоторых случаях они не так хороши, как могут показаться. Давайте проанализируем это на примере.

Вот перечисление TypeScript для перечисления фруктов в моем холодильнике:

enum FruitEnumTypeScript {
	APPLE,
	BANANA,
	MANGO
}

Теперь предположим, что я хочу просмотреть все фрукты в моем холодильнике — вот что я вижу:

const fridgeFruit = Object.keys(FruitEnumTypeScript);
console.log(fridgeFruit);
// Produces -> [(0, 1, 2, Apple, Banana, Mango)];

О-о! Почему мы вдруг видим эти магические числа в начале нашего массива?

«Скрытая» правда заключается в том, что перечисления TypeScript транспилируются в объекты JavaScript неинтуитивным способом. Как ни странно, во время выполнения перечисление в конечном итоге будет преобразовано так, чтобы оно выглядело так:

const FruitEnumTypeScript = {
  APPLE: 0,
  0: "APPLE",
  BANANA: 1,
  1: "BANANA",
  MANGO: 2,
  2: "MANGO",
};

Таким образом, выполнение обычных манипуляций с объектами JS может раздражать — например, сопоставление ключей перечисления, которое является довольно распространенной операцией для любого списка значений. Интуитивно мы ожидаем, что перечисление будет простым словарем ключей и значений перечисления, но вместо этого мы получаем странную и неинтуитивную форму.

На мой взгляд, это самый убедительный аргумент против нативных перечислений TypeScript — базовые манипуляции с данными — это обычная операция со списками, а описанная выше проблема транспиляции — легкий источник ошибок и путаницы.

🦸 POJO Enums спешит на помощь!

Как мы можем избежать странной проблемы с транспилированием объектов?

Одним из способов является использование POJO (или простых старых объектов JavaScript), которые, как следует из названия, используют простой объект JavaScript вместо перечисления TypeScript, которое будет выглядеть следующим образом:

const FruitEnumPojo = {
  APPLE: "apple",
  BANANA: "banana",
  MANGO: "mango",
} as const;

Как видно, здесь нет никаких трюков, это простой и простой объект JavaScript с утверждением as const TypeScript, которое принуждает содержимое объекта быть доступным только для чтения. Поскольку нет никакой транспиляции TypeScript, не может быть никаких неожиданных переводов и "то, что вы видите, это то, что вы получаете".

🤔 Перечисления POJO не идеальны

Таким образом, перечисления «POJO», кажется, решают проблему переноса объектов — почему бы просто не использовать эти «перечисления» JS все время?

Есть один большой недостаток — они позволяют использовать примитивные значения перечисления непосредственно в коде (например, "apple" выше) вместо того, чтобы выдавать ошибки типа и требовать что-то вроде FruitEnumPojo.APPLE.

// Helper type to get a type for possible keys of enum
type ObjectValues<T> = T[keyof T]
const test = (arg1: ObjectValues<typeof FruitEnumPojo>) => {
    ...
}
// No type errors, and does not demand FruitEnumPojo.APPLE
test('apple')

На мой взгляд, такое взаимозаменяемое использование литералов JS, таких как "apple", с фиксированными элементами перечисления, такими как FruitEnum.APPLE, поощряет использование «магических» значений без ссылки, и, таким образом,

  1. Усложняет рефакторинг, так как нет единого источника истины, и каждый экземпляр "apple" должен быть изменен, если значение члена перечисления когда-либо изменится.
  2. Снижает ясность вашего кода, потому что test("apple") может вызвать такие вопросы, как: действительно ли «яблоко» является допустимым аргументом и правильно ли оно набрано?

🧵 Мой личный выбор — строковые перечисления

Итак, что такое счастливая середина? Мой личный выбор для представления фиксированных значений — это перечисления TypeScript string, которые выглядят примерно так:

enum FruitStringEnum {
  APPLE = "apple",
  BANANA = "banana",
  MANGO = "mango",
}

По моему мнению, использование строковых перечислений избегает большинства ловушек, обычно приписываемых перечислениям TypeScript «по умолчанию». Транспилированный JS интуитивно понятен и, как и ожидалось, сопоставляет вывод перечисления JS с утверждением const следующим образом:

Object.entries(FruitStringEnum)
// Produces:
// (["APPLE", "apple"], ["BANANA", "banana"], ["MANGO", "mango"])

Кроме того, мы можем быть более строгими в отношении явного доступа к членам перечисления, а не с помощью магических значений, таких как "apple", и в противном случае выдавать ошибки типа.

// Helper type to get a type for possible keys of enum
type ObjectValues<T> = T[keyof T]
const test = (arg1: ObjectValues<typeof FruitStringEnum>) => {
    ...
}
// Type error thrown! Demands FruitStringEnum.APPLE
test("apple")
// No Type Error
test(FruitStringEnum.APPLE)

🧅 А как же Union Types?

Итак, строковые перечисления — это здорово, но как насчет типов объединений? Типы объединения — это еще один способ представления списка фиксированных значений с помощью TypeScript, но их плюсы и минусы по сравнению с перечислениями TypeScript часто путают. Прежде всего, вот как выглядит тип объединения:

type Fruits = "APPLE" | "BANANA" | "MANGO"

Удивительный! Типы союзов предлагают более краткий и удобочитаемый список значений, чем перечисления TypeScript! Ключевое различие между типами объединения и перечислениями (кроме их синтаксиса) заключается в том, что типы объединения являются понятиями исключительно времени компиляции, тогда как перечисления создают объекты JavaScript, которые будут существовать во время выполнения.

Это означает, что манипулирование объектами, запись в консоль и другие действия во время выполнения будут недоступны для типов объединения.

В некоторых случаях типы объединения действительно могут предложить преимущества, которых нет у перечислений. Например, когда существует высокая уверенность в том, что значения, которые должны быть представлены в перечислении, не требуют каких-либо сложных манипуляций с объектами, вместо этого может быть предпочтительнее использовать типы объединения в качестве быстрого и легкого способа представления списка фиксированных значений.

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

Почему в некоторых случаях нарушается безопасность типов? Потому что невозможно получить доступ к определенным элементам перечисления и их значениям в типичной для ENUM.MEMBER манере, а также невозможно указать проверку типов для конкретного члена перечисления без определения нового типа или ввода аргумента в виде строкового литерала.

По тем же причинам типы объединения требуют использования строковых литералов вместо явного доступа ENUM.MEMBER.

type Fruits = "APPLE" | "BANANA" | "MANGO"
// Cannot specify that function only takes Fruits.APPLE
const printApple = (fruit: Fruits) => {
  console.log(fruit)
}
// Cannot pass Fruits.APPLE instead of "APPLE"
printApple("APPLE")

В приведенном выше фрагменте тип объединения требует передачи литерала "APPLE", поэтому код будет сложнее реорганизовать, как и в случае с перечислением POJO.

В случае, если printApple(...) вызывается в кодовой базе несколько раз, и мы хотим обновить имя, чтобы оно было более конкретным, или исправить опечатку, нам придется делать это для каждого экземпляра вызова printApple(<STRING_TO_CHANGE>).

🧶 Итак, должны ли мы всегда использовать String Enums?

Короткий ответ: вероятно, потому что строковые перечисления TypeScript должны удовлетворять большинству обычных случаев использования идиоматических перечислений. Единственные случаи, когда строковые перечисления могут быть контрпродуктивными, могут быть, когда:

1. В команде разработчиков неопытные разработчики / плохая коммуникация

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

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

Согласно Николасу С. Закасу «Bunny Theory of Code», как только нестроковые перечисления внедряются в кодовую базу, более вероятно, что они будут скопированы между файлами, что приведет к несоответствиям. и распространение подверженного ошибкам кода.

Конечно, возможно, лучшее решение этой проблемы заключается в обучении команды лучшим практикам и надлежащем применении лучших практик (например, с помощью правил ESLint), но в проекте с сжатыми сроками и ограниченные ресурсы для обучения или рефакторинга, может быть лучше выбрать перечисления POJO, для которых сложнее использовать плохие практики или чем пожертвовать.

2. Низкая вероятность манипулирования объектом

Как уже говорилось, типы объединения могут иметь преимущество перед строковыми перечислениями, если верно следующее:

  1. Существует уверенность в том, что значения перечисления не требуют каких-либо манипуляций с объектами.
  2. Нет необходимости в безопасности типов для конкретных членов перечисления.
  3. Использование строковых литералов вместо ENUM.MEMBER для вас не важно

В таком случае типы объединения могут предложить более краткую и удобочитаемую реализацию идиомы enum.

type Fruits = "APPLE" | "BANANA" | "MANGO"

3. Вы очень заботитесь о системах структурного и номинального типа

Технически TypeScript претендует на роль «структурной» системы типов, а не «номинальной» системы типов. В системах номинальных типов каждый определенный тип считается уникальным, и, таким образом, даже если типы совместно используют одни и те же данные или значения, на них нельзя ссылаться эквивалентно.

Как структурная система типов, TypeScript заботится только о форме и значении вещей, а не об именах; «Если шрифт имеет форму утки, это утка».

При использовании перечислений TypeScript явный доступ ENUM.MEMBER нельзя использовать взаимозаменяемо со строковыми литералами, поэтому для некоторых разработчиков это может поставить под сомнение их предположения о TypeScript как о системе типов.

Это не единственный случай, когда наши предположения о TypeScript были поставлены под сомнение. Например, вернемся к проблеме транспиляции объектов — TypeScript перечисления технически ломают наши ожидания, создавая объекты JavaScript, которые существуют во время выполнения, что может вызвать путаницу, поскольку мы часто думаем, что TypeScript существует только в момент компиляции. время.

Это особенно неинтуитивно, учитывая, что это нарушает одну из нецелей TypeScript, явно заявляя, что он не должен предоставлять дополнительные функциональные возможности во время выполнения.

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