enum и его замена объектом as const
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Из всего, что TypeScript дописал к JavaScript, enum — самая заметная добавка: он единственный из повседневных приёмов создаёт объект, которого в исходнике не было. Посмотреть, во что он превращается, стоит один раз — после этого выбор между enum, объединением литералов и объектом as const перестаёт быть делом вкуса.
Объявление enum даёт сразу две сущности с одним именем — тип и значение. Двойственность знакомая: с ней вы уже встречались у класса. Разница в том, что класс есть и в JavaScript, а объект для enum компилятору приходится сочинять с нуля.
Ни одной диагностики — и четыре ключа там, где членов объявлено два: ["0", "1", "Up", "Down"]. Числовой enum заполняет объект в обе стороны: имя ведёт к числу, число — обратно к имени. Это называют обратным маппингом — единственное, что enum делает за вас и что в обычном объекте само собой не появится.
Откуда берутся четыре ключа, лучше всего видно на том, что компилятор пишет вместо объявления. Это обычный JavaScript, его можно запустить прямо здесь.
Хитрость в одной строке: присваивание возвращает присвоенное, поэтому Direction["Up"] = 0 отдаёт ноль, и он тут же становится индексом для обратной записи. Одно выражение кладёт в объект обе пары.
Отсюда практическое правило: перебирать числовой enum через Object.keys нельзя — половина ключей окажется числами.
Строковый enum устроен проще: обратной записи у него нет, потому что ключ и значение перепутались бы. В объекте останутся ровно объявленные члены, и Object.keys даст два имени.
В главе про объекты выяснилось: TypeScript сравнивает типы по строению, а не по имени — совпали поля, значит типы совместимы. enum — исключение из этого правила, и таких исключений в языке единицы.
Два TS2345 и один TS2367 — Это сравнение кажется непреднамеренным, поскольку типы "Status" и "Phase" не перекрываются. А в выводе три одинаковые строки и true. Компилятор сравнивал объявления, движок — строки; в собранном коде от Status и Phase остались два объекта с одинаковым содержимым.
Иногда это ровно то, что нужно: перепутать «статус задачи» и «фазу сделки» — настоящая ошибка, и хорошо, когда её ловят. Гораздо чаще мешает: строка приходит из формы, из адреса, из localStorage — и оказывается, что в свой же enum её не положить.
Сравнение stored === "dark" сузило тип до литерала — приём из главы про сужение. Объединению этого достаточно: "dark" входит в Theme по строению. А ThemeEnum.Dark — не строка "dark", а член конкретного объявления, и свести к нему нечего.
Живой код обходит запрет одним словом — приведением через as.
Компилятор промолчал оба раза, и во второй в функцию уехала строка, которой в enum нет вовсе. Строгость, ради которой enum и брали, держится ровно до первого as — а as на границе с чужими данными ставят постоянно.
Строковый enum строг до неудобства. Числовой — наоборот, и это стоит увидеть до того, как оно случится в бою.
Одна диагностика вместо двух. Строка с fromServer прошла целиком: значение типа number считается годным для любого числового enum. Литерал 42 строкой ниже компилятор сверил со списком членов и отказал — TS2322. Разница только в том, что в одном случае тип широкий, а в другом — конкретное число.
На запуске в role лежит сорок два, а обратный маппинг Role[role] вернул undefined. Тип обещал одно из двух значений, пришло третье, и заметить это было некому. Именно так числовой enum и подводит: данные приходят числами, числа проходят проверку, а к списку членов они отношения не имеют.
Если enum всё-таки берут, его делают строковым: string в строковый enum не пролезает, а в логах видно слово, а не безымянную цифру.
Числовые enum задумывались в том числе под побитовые флаги, где осмысленны и суммы членов. Запрети компилятор всё, чего нет в списке, — приём перестал бы работать.
Диагностик нет, в both тройка, а имени у неё нет. Приём рабочий, но платит за него каждый числовой enum в проекте, включая те, где никаких флагов не задумывалось.
Замена собирается из двух уже знакомых приёмов: as const оставляет значениям литеральные типы, а keyof typeof достаёт тип из значения. По отдельности вы их видели в главе про объекты — здесь они работают в паре.
Тип читается изнутри наружу. 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.
Если объект нужен только ради типа, объект можно вообще не писать. Объединение литералов проверяет то же самое и не оставляет в собранном файле ни строчки.
Правило простое: нужен только тип — пишите объединение; нужен ещё и список на запуске (отрисовать select, проверить пришедшее) — пишите объект as const и выводите тип из него. Дублировать список в двух местах не нужно ни в одном из случаев.
enum | union литералов | объект as const | |
|---|---|---|---|
| Остаётся в собранном файле | объект | ничего | объект |
| Принимает обычную строку | нет, только через as | да | да |
| Список значений на запуске | есть | нет | Object.values |
| Число → имя из коробки | есть у числового | нет | нет |
| Ловит значение не из списка | строковый — да, числовой — нет | да | да |
| Переживает стирание типов | нет | да | да |
Последняя строка — самая практическая. enum нельзя просто стереть: за именем стоит объект, который кто-то должен создать. Поэтому node app.ts, который типы именно стирает, на таком файле отказывается работать вовсе, а у компилятора есть флаг erasableSyntaxOnly, запрещающий такой синтаксис заранее. Подробный разбор — в следующей статье главы; для выбора между тремя вариантами достаточно знать, что enum сокращает список инструментов, которыми проект можно запустить.
Поводы всё же взять enum остаются, и их немного. Первый: библиотека объявила его в своих типах — тогда пользуйтесь её enum, а не пересобирайте свой. Второй: набор числовых кодов, для которых нужен обратный маппинг, а писать его руками лень. Третий: два набора одинаковых строк, которые нельзя путать, и вы готовы к тому, что защита снимается приведением.
У const enum объекта быть не должно: компилятор подставляет значения по месту. Поэтому перебирать его нечем — и он об этом честно предупреждает.
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" перестала быть ошибкой.
Члены переезжают в объект с as const, а тип выводится из него же. Имя Status занимается дважды — константой и типом; конфликта нет, потому что типы и значения живут в разных пространствах имён.
Ни одной диагностики: тип Status — это объединение "new" | "done", и обычная строка из списка ему подходит. Тело move и вызов move(Status.Done) при этом не менялись вовсе.
Заодно появился список на запуске: Object.values(Status) вернёт ["new", "done"] — ровно два значения, без обратного маппинга.
Поймать код, которого нет в списке
Здесь компилятор не сказал ни слова, хотя пятисотого кода в списке нет. Сделайте так, чтобы эта строка стала ошибкой, — при этом HttpCode.Ok и HttpCode.NotFound должны остаться доступными как значения.
Дыра в числовом enum не чинится: любое number ему подходит по замыслу. Лечится заменой на объект as const — объединение числовых литералов такой поблажки не даёт.
Компилятор отвечает TS2322: Тип "number" не может быть назначен для типа "HttpCode" — ровно то, чего мы добивались. Тип HttpCode здесь равен 200 | 404, и широкому number в нём места нет.
Дальше по коду raw придётся честно проверить — сравнением или списком допустимых значений. Это и есть разница между «тип пообещал» и «значение проверили».
Имя по значению
Обратный маппинг — единственное, что теряется при переходе с числового enum на объект. Напишите nameOf, которая по коду возвращает имя члена, а для неизвестного кода — строку "неизвестно".
Object.entries даёт пары «ключ — значение», остаётся найти пару с нужным значением и вернуть её ключ.
Проверка pair ? … : … нужна не для красоты: find возвращает undefined, если ничего не нашлось, и без неё при strictNullChecks компилятор скажет TS18048: Возможно, "pair" имеет значение undefined.
Четыре строки против встроенного обратного маппинга — плата за то, что в объекте останется ровно два ключа, а не четыре, и что весь этот код переживёт стирание типов.