keyof и typeof: тип из значения и его ключи

вывод типов≈ 10 минут

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

У слова typeof в TypeScript две работы. В выражении это тот же оператор, что и в части I: отвечает "object", "number" и не более того. В позиции типа — совсем другая операция: она снимает с готового значения его тип целиком. Рядом живёт keyof, который превращает тип объекта в объединение его ключей. Вместе они дают приём, после которого списки в коде перестают писать дважды.

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

Компилятор читает файл в двух режимах. После двоеточия, после type, внутри угловых скобок — это мир типов. Всё остальное — мир значений. Одно и то же слово в этих мирах может означать разное, и typeof — самый заметный такой случай.

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

В console.log сработал знакомый оператор — тот, что честно отвечает object и не умеет отличить объект от массива. В строке с type тот же typeof сделал другое: заглянул в settings и выдал его тип целиком — { theme: string; fontSize: number }. Дальше Settings живёт как обычный именованный тип, и объявление broken получило за это TS2322: Тип "string" не может быть назначен для типа "number".

Обратите внимание на вторую половину вывода: строка "14" всё равно легла в объект и напечаталась. Компилятор высказался и отошёл в сторону — до выполнения от типов не доезжает ничего, ровно как с readonly-полем из главы про объекты.

Снять тип можно не только с объекта. Функция — тоже значение, и у неё есть тип: список параметров и то, что она возвращает.

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

Параметры v и s не размечены, и всё же неявного any здесь нет: типы пришли из Formatter сверху вниз — контекстная типизация из статьи про вывод типов. А вызов с "нет" получил TS2345, но выполнился: непустая строка истинна, поэтому в консоли восклицательный знак.

Что именно вывел typeof, проще всего увидеть наведением курсора в редакторе — подсказка покажет развёрнутый тип, а не само выражение.

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

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

let можно переприсвоить, поэтому его тип расширяется до string. У const расширять нечего — остаётся литеральный тип "dark". Это та самая разница из статьи про литеральные типы, и typeof просто наследует её.

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

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

keyof User — это "id" | "name" | "email", обычное объединение из главы про сужение типов. Диагностика TS2322 напечатала его не раскрывая: Тип "age" не может быть назначен для типа "keyof User". Компилятор часто оставляет короткую запись, если она читается лучше развёрнутой; развёрнутый вариант всегда покажет редактор при наведении.

Главное про keyof — это не «список строк». Это список, привязанный к типу: поменяли User, и UserKey изменился в ту же секунду, а все места, где ключи перечислялись, компилятор проверит заново.

Чаще всего ключи нужны не от интерфейса, а от конкретного объекта — словаря, таблицы настроек, набора обработчиков. Первое, что хочется написать, — keyof settings. Так нельзя, и причина ровно та, с которой начиналась статья.

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

Две зеркальные диагностики. TS2749: "settings" относится к значению, но здесь используется как тип — и тут же подсказывает недостающее слово — Возможно, вы имели в виду "typeof settings"? TS2693: "Config" относится только к типу, но используется здесь как значение — интерфейс существует лишь на этапе проверки, брать у него typeof нечего.

Отсюда и связка keyof typeof. Читается справа налево: typeof settings достаёт тип из значения, keyof берёт у этого типа ключи. Два оператора, две ступени, и ни одной строки в собранном коде.

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

TS2345 развернул объединение полностью: Аргумент типа "GBP" нельзя назначить параметру типа "RUB" | "USD" | "EUR". Список валют написан один раз — в самом объекте; тип подтянулся сам. Добавьте туда четвёртую строку, и Currency расширится без единой правки.

Вторая строка вывода — про то, чего типы не могут. Предупреждение проигнорировали, выполнение продолжилось, обращение к несуществующему ключу вернуло undefined. Функция обещала string и вернула undefined — компилятор не соврал, просто он проверяет исходник, а не то, что происходит с ним потом.

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

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

Аргумент типа "string" нельзя назначить параметру типа "RUB" | "USD" | "EUR". Обидно: объект прямо здесь, ключи известны, а key оказался просто строкой. Дело в сигнатуре: Object.keys объявлена как возвращающая string[] — без всякой связи с тем, что ей передали.

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

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

Ни одной диагностики — и BTC undefined в консоли. Утверждение as Code[] сказало компилятору «здесь только мои ключи», проверять он это не стал, а объект пришёл с третьим полем. Проверка лишних свойств срабатывает на литералах, а fromServer положили через переменную — и она промолчала.

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

Если ключи заранее известны, надёжнее объявить их списком с явным типом: тогда расхождение со словарём ловится сразу, а не превращается в undefined посреди цикла.

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

Список приходится держать рядом со словарём, зато он под присмотром: удалили ключ из rates — красная строка появится в CODES. Для объекта, который вы сами написали в этом же файле, as Code[] тоже приемлемо; опасность начинается там, где объект пришёл извне.

У keyof простое правило и несколько неочевидных следствий. Первое встречается сразу, как только под руку попадает тип с индексной сигнатурой.

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

Компилятор промолчал на обеих строках: keyof от индексной сигнатуры со строковым ключом — это string | number. Число попало в набор не по ошибке. В JavaScript ключом бывает строка или символ, а число приводится к строке: obj[0] — то же самое, что obj["0"]; раз запись с числом разрешена, число обязано входить в объединение ключей.

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

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

Тип "radius" не может быть назначен для типа "kind" — от объединения keyof оставил только общие ключи. И это единственный безопасный ответ: значение типа Shape может оказаться квадратом, у которого никакого radius нет. Если общих полей у вариантов не окажется вовсе, keyof честно вернёт never — тип без единого значения.

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

Массив — тоже объект, и ключи у него не только числовые: методы map, filter, свойство length — всё это его поля.

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

Обе строки прошли. Поэтому keyof к массивам почти не применяют: для индекса берут number, а чтобы получить объединение самих элементов, пишут (typeof list)[number] — запись из следующей статьи.

Типkeyof от него
{ id: number; name: string }"id" | "name"
{ [key: string]: number }string | number
{ [key: number]: string }number
A | Bтолько общие ключи, иначе never
string[]number и все имена методов массива
anystring | number | symbol

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

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

Оба обращения к icons сработали одинаково — но одно из них компилятор считает ошибкой, потому что сверяется с устаревшим списком. Тип Derived такой проблемы иметь не может в принципе: он не копия объекта, а его отражение.

Четыре записи закрывают почти все повседневные случаи. Последняя достаёт из типа не ключи, а сами значения — её подробно разбирает следующая статья.

Что нужноКак записать
тип готового значенияtypeof value
ключи типаkeyof Type
ключи готового объектаkeyof typeof value
значения готового объекта(typeof value)[keyof typeof value]

И ещё одно место, где keyof встречается постоянно, — ограничения дженериков: запись K extends keyof T означает «ключ, который у этого объекта действительно есть». Идея та же самая, просто тип, у которого берут ключи, приходит параметром.

  • В позиции типа typeof — не оператор из части I: он снимает с готового значения его выведенный тип целиком, включая функции.
  • keyof берёт тип объекта и отдаёт объединение его ключей — литеральные типы имён, а не string.
  • keyof ждёт тип, а переменная — значение; отсюда TS2749 и связка keyof typeof value, которая читается справа налево.
  • Object.keys всегда возвращает string[]: тип не запрещает объекту иметь лишние поля, поэтому честного keyof там быть не может.
  • У индексной сигнатуры keyof даёт string | number, у объединения — только общие ключи, а при их отсутствии never.
  • Смысл всей пары — один источник правды: список пишется в объекте, а тип выводится из него и не может отстать.

1/4В коде есть const box = { w: 10, h: 20 };. Автор пишет type K = keyof box;. Что произойдёт?

2/4Объявлены const rates = { RUB: 1, USD: 95 }; и type Code = keyof typeof rates;. Какого типа будут элементы у Object.keys(rates)?

3/4Есть type A = { id: number; note: string } и type B = { id: number; total: number }. Что даст keyof (A | B)?

4/4let mode = "dark"; и const fixed = "dark";. Чем отличаются typeof mode и typeof fixed?

Роли в одном месте

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

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

Список ключей у нас уже есть — это сам объект ROLES. Осталось попросить его у компилятора: typeof ROLES даёт тип объекта, keyof — объединение его ключей.

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

Теперь Role — это "admin" | "editor" | "viewer", обе строки выполняются без диагностик. Добавили в ROLES четвёртую роль — тип расширился сам; удалили роль — компилятор покажет все места, где на неё ссылались.

Перебрать словарь

Цикл должен напечатать каждую валюту с её курсом, но компилятор ругается на show(key). Объясните, почему тип Code здесь не помогает, и почините перебор двумя способами: коротким и безопасным.

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

Object.keys объявлена как возвращающая string[] — она не смотрит на переданный объект, и тип ключей теряется. Короткий способ — утверждение типа:

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

Компилятор замолчит, но проверять ничего не будет: если в объект попадёт лишнее поле, show получит ключ, которого в типе нет. Для объекта, написанного тут же рядом, это приемлемо.

Безопасный способ — перебирать проверенный список, а не сам объект:

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

Здесь каждая строка списка сверена с ключами rates: опечатка вроде "UDS" станет ошибкой прямо в объявлении.

Ключи двух питомцев

Тип PetKey должен принимать любое поле, встречающееся хоть у кошки, хоть у собаки. Сейчас вторая строка не проходит. Почините объявление и объясните, почему keyof от объединения ведёт себя именно так.

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

keyof от объединения оставляет только общие ключи — здесь это одно поле name, поэтому строка с "bark" получает TS2322. Иначе и быть не может: значение типа Pet вполне может оказаться кошкой, у которой поля bark нет, и обещать доступ к нему компилятор не вправе.

Нужный набор получается объединением результатов, а не keyof от объединения:

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

Теперь PetKey — это "name" | "meow" | "bark", обе строки проходят. Важно помнить, что такой ключ уже не гарантирует доступ к полю у конкретного питомца — он лишь перечисляет имена.