enum и его замена объектом as const

классы≈ 9 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Из всего, что TypeScript дописал к JavaScript, enum — самая заметная добавка: он единственный из повседневных приёмов создаёт объект, которого в исходнике не было. Посмотреть, во что он превращается, стоит один раз — после этого выбор между enum, объединением литералов и объектом as const перестаёт быть делом вкуса.

Разделы темы:·

Объявление enum даёт сразу две сущности с одним именем — тип и значение. Двойственность знакомая: с ней вы уже встречались у класса. Разница в том, что класс есть и в JavaScript, а объект для enum компилятору приходится сочинять с нуля.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и четыре ключа там, где членов объявлено два: ["0", "1", "Up", "Down"]. Числовой enum заполняет объект в обе стороны: имя ведёт к числу, число — обратно к имени. Это называют обратным маппингом — единственное, что enum делает за вас и что в обычном объекте само собой не появится.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Хитрость в одной строке: присваивание возвращает присвоенное, поэтому Direction["Up"] = 0 отдаёт ноль, и он тут же становится индексом для обратной записи. Одно выражение кладёт в объект обе пары.

Отсюда практическое правило: перебирать числовой enum через Object.keys нельзя — половина ключей окажется числами.

Строковый enum устроен проще: обратной записи у него нет, потому что ключ и значение перепутались бы. В объекте останутся ровно объявленные члены, и Object.keys даст два имени.

В главе про объекты выяснилось: TypeScript сравнивает типы по строению, а не по имени — совпали поля, значит типы совместимы. enum — исключение из этого правила, и таких исключений в языке единицы.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два TS2345 и один TS2367 — Это сравнение кажется непреднамеренным, поскольку типы "Status" и "Phase" не перекрываются. А в выводе три одинаковые строки и true. Компилятор сравнивал объявления, движок — строки; в собранном коде от Status и Phase остались два объекта с одинаковым содержимым.

Иногда это ровно то, что нужно: перепутать «статус задачи» и «фазу сделки» — настоящая ошибка, и хорошо, когда её ловят. Гораздо чаще мешает: строка приходит из формы, из адреса, из localStorage — и оказывается, что в свой же enum её не положить.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сравнение stored === "dark" сузило тип до литерала — приём из главы про сужение. Объединению этого достаточно: "dark" входит в Theme по строению. А ThemeEnum.Dark — не строка "dark", а член конкретного объявления, и свести к нему нечего.

Живой код обходит запрет одним словом — приведением через as.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор промолчал оба раза, и во второй в функцию уехала строка, которой в enum нет вовсе. Строгость, ради которой enum и брали, держится ровно до первого as — а as на границе с чужими данными ставят постоянно.

Строковый enum строг до неудобства. Числовой — наоборот, и это стоит увидеть до того, как оно случится в бою.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна диагностика вместо двух. Строка с fromServer прошла целиком: значение типа number считается годным для любого числового enum. Литерал 42 строкой ниже компилятор сверил со списком членов и отказал — TS2322. Разница только в том, что в одном случае тип широкий, а в другом — конкретное число.

На запуске в role лежит сорок два, а обратный маппинг Role[role] вернул undefined. Тип обещал одно из двух значений, пришло третье, и заметить это было некому. Именно так числовой enum и подводит: данные приходят числами, числа проходят проверку, а к списку членов они отношения не имеют.

Если enum всё-таки берут, его делают строковым: string в строковый enum не пролезает, а в логах видно слово, а не безымянную цифру.

Числовые enum задумывались в том числе под побитовые флаги, где осмысленны и суммы членов. Запрети компилятор всё, чего нет в списке, — приём перестал бы работать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Замена собирается из двух уже знакомых приёмов: as const оставляет значениям литеральные типы, а keyof typeof достаёт тип из значения. По отдельности вы их видели в главе про объекты — здесь они работают в паре.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип читается изнутри наружу. typeof Level — тип самого объекта, { readonly Debug: "debug"; readonly Error: "error"; }. keyof typeof Level — объединение ключей, "Debug" | "Error". Индексный доступ по этому объединению спрашивает «что лежит под любым из ключей» и отдаёт объединение значений: "debug" | "error".

Имя Level занято одновременно константой и типом — и это не конфликт: типы и значения TypeScript держит в разных пространствах имён. Снаружи выходит та же двойственность, что у enum: Level.Error — значение, : Level — тип.

Что получилось взамен. Level.Error пишется как у enum. Object.values(Level) даёт готовый список для перебора — и в нём ровно два элемента, без обратного маппинга. Обычная строка "debug" проходит без приведений, потому что тип — обычное объединение литералов. А "warn" не прошло: TS2345.

Если объект нужен только ради типа, объект можно вообще не писать. Объединение литералов проверяет то же самое и не оставляет в собранном файле ни строчки.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило простое: нужен только тип — пишите объединение; нужен ещё и список на запуске (отрисовать select, проверить пришедшее) — пишите объект as const и выводите тип из него. Дублировать список в двух местах не нужно ни в одном из случаев.

enumunion литераловобъект as const
Остаётся в собранном файлеобъектничегообъект
Принимает обычную строкунет, только через asдада
Список значений на запускеестьнетObject.values
Число → имя из коробкиесть у числовогонетнет
Ловит значение не из спискастроковый — да, числовой — нетдада
Переживает стирание типовнетдада

Последняя строка — самая практическая. enum нельзя просто стереть: за именем стоит объект, который кто-то должен создать. Поэтому node app.ts, который типы именно стирает, на таком файле отказывается работать вовсе, а у компилятора есть флаг erasableSyntaxOnly, запрещающий такой синтаксис заранее. Подробный разбор — в следующей статье главы; для выбора между тремя вариантами достаточно знать, что enum сокращает список инструментов, которыми проект можно запустить.

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

У const enum объекта быть не должно: компилятор подставляет значения по месту. Поэтому перебирать его нечем — и он об этом честно предупреждает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2475: Перечисления const можно использовать только в выражениях доступа к свойству или индексу…. А следом песочница напечатала все четыре ключа — то есть объект всё-таки был.

Причина в том, как собирают файл. Песочница компилирует его поодиночке, как это делают esbuild, SWC и компилятор внутри Vite: такому однофайловому компилятору неоткуда узнать значения членов, и он оставляет обычный enum. Настоящий tsc, который видит программу целиком, написал бы console.log(2 /* Size.Big */) и не создал бы ничего. Вывод: там, где файлы компилируются поодиночке, — в Vite и в песочнице — const enum не даёт ничего, кроме лишних запретов.

Перевод существующего enum на объект as const — почти механический: значения переехали в объект, имя типа осталось прежним, вызовы Level.Error менять не нужно.

  • enum — единственный повседневный приём TypeScript, который создаёт значение: после сборки на его месте стоит настоящий объект.
  • Числовой enum заполняется в обе стороны, поэтому у него вдвое больше ключей, чем объявлено членов, а перебор через Object.keys ломается.
  • enum сверяется по объявлению, а не по строению: одинаковые по содержимому enum несовместимы, и обычная строка в них не проходит — до первого as.
  • В числовой enum пролезает любое number: тип обещает список, а получить можно что угодно.
  • Объект as const плюс (typeof X)[keyof typeof X] даёт ту же пару «значение и тип», принимает обычные строки и оставляет ровно те ключи, что объявлены.
  • Нужен только тип — хватит объединения литералов: оно ничего не оставляет в сборке и переживает стирание типов, чего enum не умеет.

1/4Объявлен enum Direction { Up, Down }. Сколько ключей вернёт Object.keys(Direction) на запуске?

2/4Два строковых enum объявляют член Done = "done". Функция ждёт Status, а ей передали Phase.Done. Что произойдёт?

3/4enum HttpCode { Ok = 200, NotFound = 404 }, есть const raw: number = 500. Что скажет компилятор на const code: HttpCode = raw?

4/4Что обязательно, чтобы type Level = (typeof Level)[keyof typeof Level] дало объединение "debug" | "error", а не просто string?

Перевести enum на объект

Функция move принимает статус задачи. Значение приходит из формы обычной строкой, и enum её не пропускает. Перепишите объявление на объект as const так, чтобы Status.Done продолжало работать, а строка "new" перестала быть ошибкой.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Члены переезжают в объект с as const, а тип выводится из него же. Имя Status занимается дважды — константой и типом; конфликта нет, потому что типы и значения живут в разных пространствах имён.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ни одной диагностики: тип Status — это объединение "new" | "done", и обычная строка из списка ему подходит. Тело move и вызов move(Status.Done) при этом не менялись вовсе.

Заодно появился список на запуске: Object.values(Status) вернёт ["new", "done"] — ровно два значения, без обратного маппинга.

Поймать код, которого нет в списке

Здесь компилятор не сказал ни слова, хотя пятисотого кода в списке нет. Сделайте так, чтобы эта строка стала ошибкой, — при этом HttpCode.Ok и HttpCode.NotFound должны остаться доступными как значения.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дыра в числовом enum не чинится: любое number ему подходит по замыслу. Лечится заменой на объект as const — объединение числовых литералов такой поблажки не даёт.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Компилятор отвечает TS2322: Тип "number" не может быть назначен для типа "HttpCode" — ровно то, чего мы добивались. Тип HttpCode здесь равен 200 | 404, и широкому number в нём места нет.

Дальше по коду raw придётся честно проверить — сравнением или списком допустимых значений. Это и есть разница между «тип пообещал» и «значение проверили».

Имя по значению

Обратный маппинг — единственное, что теряется при переходе с числового enum на объект. Напишите nameOf, которая по коду возвращает имя члена, а для неизвестного кода — строку "неизвестно".

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Object.entries даёт пары «ключ — значение», остаётся найти пару с нужным значением и вернуть её ключ.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверка pair ? … : … нужна не для красоты: find возвращает undefined, если ничего не нашлось, и без неё при strictNullChecks компилятор скажет TS18048: Возможно, "pair" имеет значение undefined.

Четыре строки против встроенного обратного маппинга — плата за то, что в объекте останется ровно два ключа, а не четыре, и что весь этот код переживёт стирание типов.