Разработчик опечатался в строке статуса заказа, написав лишнюю букву. Код собрался без жалоб, тесты прошли, а на проде заказы перестали переходить в состояние оплаты. Ошибка обнаружилась через сутки и стоила нескольких часов разбирательств. Есть механизм, который поймал бы её при сборке за секунду, и он появился в языке ещё в первой версии.
Перечисления появились вместе с языком и пережили долгую эволюцию
Сам язык увидел свет первого октября 2012 года, через два года внутренней разработки. Его создатель известен работами над несколькими популярными языками, и неудивительно, что в новую разработку перекочевали знакомые конструкции. Одна из них это перечисление, то есть именованный набор констант.
Замысел прост. Вместо того чтобы разбрасывать по коду строки и числа, разработчик описывает набор допустимых значений в одном месте и даёт каждому понятное имя. Компилятор следит, чтобы в переменную не попало ничего постороннего, а редактор подсказывает возможные варианты.
За годы язык менялся, добавились дженерики и другие возможности, а компилятор даже переписали на другом языке ради скорости. Перечисления остались, но вокруг них за это время накопился спор о том, стоит ли их использовать вовсе. Разберёмся, откуда взялся спор и что выбрать в конкретном проекте.
Числовое перечисление присваивает имена по порядку и умеет обратное отображение
Простейший вид перечисления это набор именованных чисел.
enum OrderStatus {
Created,
Paid,
Shipped,
Delivered,
}
console.log(OrderStatus.Created);
console.log(OrderStatus[2]);
Первое обращение выведет ноль, второе вернёт строку с названием состояния по числу. Обратное отображение возникает потому, что компилятор создаёт объект с двумя наборами свойств: имя к значению и значение к имени.
Эта особенность редко бывает полезной и часто приводит к путанице. Проверка наличия свойства по числу может дать неожиданный результат, а структура занимает вдвое больше места в собранном коде, чем ожидается.
Нумерация с нуля тоже не всегда удобна. Если значения уходят в базу данных или передаются по сети, добавление нового состояния в середину списка сдвинет все последующие числа и сломает совместимость с уже сохранёнными записями. Присвоение значений вручную решает эту проблему:
enum OrderStatus {
Created = 10,
Paid = 20,
Shipped = 30,
Delivered = 40,
}
Теперь новый статус можно вставить между десяткой и двадцаткой, дав ему значение пятнадцать, и старые записи не пострадают.
Строковое перечисление читается в отладке и безопаснее в хранении
Второй вид перечисления задаёт значения строками. Обратного отображения здесь нет, зато при выводе в журнал или в ответе сервера сразу видно осмысленное слово, а не число.
enum PaymentMethod {
Card = "card",
Cash = "cash",
Transfer = "transfer",
}
function describe(method: PaymentMethod): string {
switch (method) {
case PaymentMethod.Card:
return "Оплата картой";
case PaymentMethod.Cash:
return "Оплата наличными";
case PaymentMethod.Transfer:
return "Банковский перевод";
default: {
const exhaustive: never = method;
return exhaustive;
}
}
}
Последняя ветка заслуживает отдельного внимания. Переменная типа никогда не получает значения, и если добавить в перечисление новое состояние, не дописав ветку, компилятор пожалуется. Это защита от забытых вариантов, и она работает только при включённой строгой проверке необъявленных случаев.
Строковые перечисления хороши ещё и тем, что сами значения читаемы человеком. В базе данных вместо цифры лежит слово, и разбирать выгрузку становится проще. При передаче по сети значение остаётся стабильным, если строку не переименовали.
Обратная сторона это отсутствие обратного отображения и чуть больший размер строки по сравнению с числом. Для большинства задач это несущественно, а для мобильного трафика при миллионах записей может стать заметным.
Слияние объявлений создаёт ловушку, о которой узнают поздно
У перечислений есть особенность, унаследованная от общей модели языка. Несколько объявлений с одним именем объединяются в одно. Это позволяет разделять члены по разным файлам, но открывает путь к неожиданному поведению.
Представим, что два разработчика независимо добавили свои значения в одно и то же перечисление. В результате получится расширенный набор, и функция, написанная под исходный список, столкнётся с незнакомым значением. Компилятор при этом не выдаст ошибку до тех пор, пока не включена проверка исчерпывающих веток.
enum Direction {
Up = "up",
}
enum Direction {
Down = "down",
}
console.log(Direction);
В собранном коде объект будет содержать оба значения. На практике это чаще вредит, чем помогает, поэтому слияние стоит считать нежелательным побочным эффектом, а не удобством.
Второе, что стоит помнить, это поведение при сборке. Перечисление превращается в объект, существующий во время выполнения. Значит, оно попадает в итоговый пакет и не может быть удалено как неиспользуемый код без специальных настроек.
Постоянные объекты и объединения строк заменяют перечисление
Именно из-за этих особенностей появилась рекомендация обходиться без перечислений. Вместо них применяются два приёма.
Первый это постоянный объект с объявленным типом. Он ведёт себя как словарь значений и легко превращается в тип объединения.
const ORDER_STATUS = {
Created: "created",
Paid: "paid",
Shipped: "shipped",
Delivered: "delivered",
} as const;
type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];
Ключевое слово в конце объявления делает все свойства доступными только для чтения и сохраняет точные строковые типы. Из них собирается объединение, которое работает во всех проверках так же, как перечисление.
Преимущества заметны сразу. Объект можно передавать между модулями, разбирать перебором ключей, сериализовать без сюрпризов. В собранный код попадает только то, что реально используется.
Второй приём это объявление типа напрямую. Если значения нужны только для проверки и не требуют перебора во время выполнения, объект вообще не нужен.
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
function request(method: HttpMethod, url: string) {
return fetch(url, { method });
}
Такой подход даёт нулевые затраты во время выполнения: типы стираются при компиляции, и в итоговом коде не остаётся ничего лишнего.
Сравнение подходов на конкретных примерах показывает границы применимости
Чтобы выбрать, полезно посмотреть, как один и тот же сценарий выглядит в обоих вариантах.
Пусть приложение отображает набор виджетов на панели. Нужно перечислить допустимые виды, получить подпись для каждого и показать их списком.
Вариант с перечислением выглядит так:
enum WidgetKind {
Chart = "chart",
Table = "table",
Text = "text",
}
const WIDGET_TITLES: Record<WidgetKind, string> = {
[WidgetKind.Chart]: "График",
[WidgetKind.Table]: "Таблица",
[WidgetKind.Text]: "Текст",
};
const allKinds = Object.values(WidgetKind);
Здесь есть полезная проверка: объект подписей объявлен с типом записи по всем значениям перечисления. Если добавить новый вид и забыть подпись, компилятор сообщит об этом.
Вариант с постоянным объектом даёт то же самое:
const WIDGET_KIND = {
Chart: "chart",
Table: "table",
Text: "text",
} as const;
type WidgetKind = (typeof WIDGET_KIND)[keyof typeof WIDGET_KIND];
const WIDGET_TITLES: Record<WidgetKind, string> = {
chart: "График",
table: "Таблица",
text: "Текст",
};
const allKinds = Object.values(WIDGET_KIND);
Функционально примеры совпадают. Разница проявляется в деталях. Во втором случае значения перечисления это обычные строки, поэтому их можно сравнить с ответом сервера без приведения типов. Объект можно импортировать в любой модуль и использовать там, где ожидается словарь.
Посчитаем размер. Перечисление из трёх элементов превращается в объект с шестью свойствами, потому что добавляется обратное отображение для числовых вариантов. Для строкового перечисления обратного отображения нет, и размер сопоставим с постоянным объектом. Это ещё один довод в пользу строкового варианта, если перечисления всё же используются.
Практические правила выбора сводятся к нескольким вопросам
Первый вопрос: нужны ли значения во время выполнения. Если нужен перебор, зависимость типов от значений и передача словаря наружу, подойдут и перечисление, и постоянный объект. Если достаточно проверки на этапе компиляции, лучше объединение строк.
Второй вопрос: есть ли необходимость в обратном отображении числа в имя. На практике такая задача возникает редко, обычно для отладочных панелей. Если она действительно нужна, проще написать отдельную функцию, чем полагаться на встроенную особенность.
Третий вопрос: участвуют ли значения в обмене с внешними системами. Строковые значения переживают добавление новых вариантов спокойнее числовых, поскольку не зависят от порядка объявления.
Четвёртый вопрос: как устроена сборка. Если проект использует изоляцию модулей и стирание типов, некоторые виды перечислений работать не будут. Постоянные объекты лишены этого ограничения.
Пятый вопрос: что принято в команде. Единообразие важнее личных предпочтений. Если в проекте уже используются перечисления, добавление пары постоянных объектов создаст путаницу. Разумнее либо придерживаться выбранного пути, либо провести миграцию целиком.
Миграция проходит без спешки и без ломки совместимости
Переход с перечислений на постоянные объекты можно выполнить постепенно.
Первый шаг это инвентаризация. Выписываются все перечисления в проекте и отмечается, где используются их особенности: обратное отображение, слияние объявлений, приведение числа к типу.
Второй шаг это замена простых случаев. Перечисления, значения которых нигде не перебираются и не преобразуются в числа, меняются на постоянные объекты без изменения поведения.
Третий шаг это работа со сложными случаями. Там, где использовалось обратное отображение, добавляется вспомогательная функция. Она делает намерение явным и избавляет от скрытой магии компилятора.
Четвёртый шаг это строгие настройки. Включённая проверка необъявленных случаев и запрет на неявные типы заставляют компилятор находить места, где новый вариант не обработан. Без этих настроек польза от типизации снижается.
Пятый шаг это тесты. Изменение представления констант не должно менять поведение приложения, и тесты подтверждают, что этого не произошло. Если тестов нет, стоит начать с тех мест, где значения уходят в базу или по сети.
Разбор реальной ошибки показывает цену неточной типизации
Вернёмся к случаю из начала и разберём его подробнее, потому что он типичен.
Приложение обрабатывало оплату и переводило заказ в состояние, полученное от платёжной службы. Обработчик сравнивал пришедшую строку с набором известных значений и в случае совпадения обновлял запись.
Строка состояния бралась из внешнего источника и никак не проверялась. Опечатка возникла не в коде приложения, а в настройке внешней службы: администратор указал статус с ошибкой. Приложение приняло незнакомое значение, не нашло совпадений в цепочке условий, прошло дальше по коду и оставило заказ в исходном состоянии.
Ошибку можно было поймать четырьмя способами.
Первый это типизация входа. Если состояние объявлено объединением строк, проверка приходит на границе системы, где данные попадают внутрь. Одного места хватает, чтобы отсечь всё постороннее.
Второй это исчерпывающий разбор. Если бы цепочка условий заканчивалась веткой с типом никогда, компилятор потребовал бы обработать все известные значения и не позволил бы молча пропустить неизвестное.
Третий это проверка схемы во время выполнения. Типы исчезают при сборке и не защищают от данных, пришедших из сети. Для таких случаев применяются библиотеки проверки, которые сверяют структуру ответа с описанием и отклоняют несоответствия.
Четвёртый это журналирование. Запись незнакомого состояния в журнал с уровнем предупреждения позволила бы заметить проблему в первые минуты, а не через сутки.
Ни один из способов не является лишним. Типы защищают внутри приложения, проверка схемы на границе, журнал ловит то, что проскочило.
Как выбрать между перечислением и объединением в большом проекте
В небольшой кодовой базе разница между конструкциями почти незаметна. В крупном проекте на тысячи файлов начинают проявляться детали.
Первая деталь это скорость сборки. Каждое перечисление создаёт объект и требует дополнительной работы компилятора. На проекте с сотнями перечислений разница в секундах заметна при каждом запуске.
Вторая деталь это размер пакета. Строковые перечисления добавляют немного, числовые больше из-за обратного отображения. При жёстких требованиях к весу начальной загрузки это имеет значение.
Третья деталь это читаемость импорта. Постоянный объект импортируется как обычная переменная и ведёт себя предсказуемо во всех сборщиках. Перечисление иногда требует дополнительных настроек, если проект использует изоляцию модулей.
Четвёртая деталь это совместимость с инструментами. Генераторы кода, генераторы документации и линтеры по-разному понимают перечисления. Постоянные объекты и объединения строк поддерживаются везде одинаково.
Пятая деталь это обучение новых сотрудников. Разработчик, знакомый с другими языками, ожидает от перечисления привычного поведения и удивляется обратному отображению и слиянию. Постоянный объект не создаёт ложных ожиданий.
Если проект только начинается, разумно выбрать объединения строк как основу, добавляя постоянные объекты там, где нужен перебор во время выполнения. Такой набор покрывает все задачи и не содержит скрытых эффектов.
В проекте, где перечисления уже используются широко, резкая миграция не нужна. Достаточно не добавлять новые и постепенно заменять те, что встречаются при изменении кода. Через год картина выровняется сама, а риск сломать работающее останется нулевым.
Типизация констант получилась редким примером конструкции, вокруг которой спорят не из-за её вреда, а из-за обилия альтернатив. Перечисление честно решает задачу именованного набора и добавляет несколько неожиданных свойств. Постоянный объект делает то же самое без дополнительных эффектов. Выбор между ними не влияет на архитектуру, зато влияет на количество часов, потраченных на отладку. Именно поэтому на опечатку в строке статуса лучше отвечать типом, чем внимательностью.